changing default for log rotation
This commit is contained in:
parent
1fd4b9f7c1
commit
45efdf1524
1 changed files with 55 additions and 22 deletions
|
|
@ -38,23 +38,43 @@ DEFAULT_BANNER = "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4"
|
|||
_event_logger = logging.getLogger("honeypot.events") # -> human-readable
|
||||
_json_logger = logging.getLogger("honeypot.json") # -> JSON Lines
|
||||
|
||||
# Size at which a log file rotates — only applies when rotation is enabled
|
||||
# (see _log_handler).
|
||||
LOG_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
def setup_logging(log_dir: str) -> None:
|
||||
|
||||
def _log_handler(path: str, backup_count: int) -> RotatingFileHandler:
|
||||
"""File handler for one log file; ``backup_count <= 0`` disables rotation.
|
||||
|
||||
Rotation only ever *prunes* — the oldest segment is deleted to make room.
|
||||
For a honeypot that is the wrong default: losing captured attempts is worse
|
||||
than a large file. So unless a backup count is configured we pass
|
||||
``maxBytes=0``, which makes RotatingFileHandler never roll over, and the
|
||||
file simply grows. Watch disk usage if you leave it that way.
|
||||
"""
|
||||
return RotatingFileHandler(
|
||||
path,
|
||||
maxBytes=LOG_MAX_BYTES if backup_count > 0 else 0,
|
||||
backupCount=max(backup_count, 0),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def setup_logging(log_dir: str, backup_count: int = 0) -> None:
|
||||
"""Configure three sinks: JSON Lines file, text file, and stdout.
|
||||
|
||||
Both ``_event_logger`` and ``_json_logger`` are configured here. They do
|
||||
not propagate to the root logger so paramiko's transport chatter stays out
|
||||
of our event logs.
|
||||
|
||||
``backup_count`` is how many rotated segments to keep per file; 0 (the
|
||||
default) means never rotate. The console copy is never rotated — that is
|
||||
the container runtime's job.
|
||||
"""
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# JSON Lines: one json.dumps() record per line, no extra formatting.
|
||||
json_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, "honeypot.jsonl"),
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
json_handler = _log_handler(os.path.join(log_dir, "honeypot.jsonl"), backup_count)
|
||||
json_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger.addHandler(json_handler)
|
||||
_json_logger.setLevel(logging.INFO)
|
||||
|
|
@ -64,12 +84,7 @@ def setup_logging(log_dir: str) -> None:
|
|||
# Local-time, space-separated, no timezone clutter — easy to scan in a terminal.
|
||||
text_fmt = logging.Formatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
text_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, "honeypot.log"),
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
text_handler = _log_handler(os.path.join(log_dir, "honeypot.log"), backup_count)
|
||||
text_handler.setFormatter(text_fmt)
|
||||
|
||||
stdout_handler = logging.StreamHandler() # defaults to stderr/stdout stream
|
||||
|
|
@ -80,8 +95,15 @@ def setup_logging(log_dir: str) -> None:
|
|||
_event_logger.setLevel(logging.INFO)
|
||||
_event_logger.propagate = False
|
||||
|
||||
# Keep paramiko's internal logging quiet but capture genuine errors.
|
||||
logging.getLogger("paramiko").setLevel(logging.WARNING)
|
||||
# Silence paramiko's own logging. An internet-facing honeypot constantly
|
||||
# gets half-open / non-SSH probes (port scanners, nc, health checks) that
|
||||
# make paramiko emit noisy "Error reading SSH protocol banner" tracebacks.
|
||||
# We already record a clean `connection_error` line for these ourselves, so
|
||||
# raise paramiko above ERROR and give it a NullHandler so nothing falls
|
||||
# through to Python's last-resort stderr handler.
|
||||
paramiko_log = logging.getLogger("paramiko")
|
||||
paramiko_log.setLevel(logging.CRITICAL)
|
||||
paramiko_log.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def _sanitize(value) -> str:
|
||||
|
|
@ -105,7 +127,10 @@ def _human_message(event: str, fields: dict) -> str:
|
|||
fields that matter) instead of a generic key=value dump, so a stream of
|
||||
attempts is easy to read at a glance.
|
||||
"""
|
||||
src = f"{fields['src_ip']}:{fields.get('src_port', '?')}" if "src_ip" in fields else ""
|
||||
# Source IP and port as two separate aligned columns (IPv4 fits in 15, port
|
||||
# in 5). They are already separate fields in the JSON Lines output.
|
||||
src = (f"ip={fields['src_ip']:<15} port={fields.get('src_port', '?')!s:<5}"
|
||||
if "src_ip" in fields else "")
|
||||
|
||||
if event == "login_attempt":
|
||||
if fields.get("auth_method") == "password":
|
||||
|
|
@ -114,13 +139,13 @@ def _human_message(event: str, fields: dict) -> str:
|
|||
cred = (f"user={fields.get('username')!r} "
|
||||
f"key={fields.get('key_type')}/{fields.get('key_fingerprint')}")
|
||||
client = _sanitize(fields.get("client_version", ""))
|
||||
return f"{src:<21} {cred} [{fields.get('auth_method')}] {client}".rstrip()
|
||||
return f"{src} {cred} [{fields.get('auth_method')}] {client}".rstrip()
|
||||
if event == "connection":
|
||||
return f"{src:<21} new connection"
|
||||
return f"{src} new connection"
|
||||
if event == "disconnect":
|
||||
return f"{src:<21} disconnected"
|
||||
return f"{src} disconnected"
|
||||
if event == "connection_error":
|
||||
return f"{src:<21} error: {_sanitize(fields.get('error'))}"
|
||||
return f"{src} error: {_sanitize(fields.get('error'))}"
|
||||
if event == "server_start":
|
||||
return (f"listening on {fields.get('bind')}:{fields.get('port')} "
|
||||
f"banner={fields.get('banner')!r} fingerprint={fields.get('host_key_fingerprint')}")
|
||||
|
|
@ -345,7 +370,10 @@ class Honeypot:
|
|||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _env_default(name: str, fallback: str) -> str:
|
||||
return os.environ.get(name, fallback)
|
||||
# `or fallback` rather than a .get() default: a set-but-empty variable is
|
||||
# common in quadlet/compose files and systemd unit drop-ins, and .get()
|
||||
# would hand back "" — which then crashes int() for the numeric options.
|
||||
return os.environ.get(name) or fallback
|
||||
|
||||
|
||||
def parse_args(argv=None) -> argparse.Namespace:
|
||||
|
|
@ -359,6 +387,11 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|||
help="Path to the persisted host key (env HONEYPOT_HOST_KEY)")
|
||||
p.add_argument("--log-dir", default=_env_default("HONEYPOT_LOG_DIR", "./logs"),
|
||||
help="Directory for log files (env HONEYPOT_LOG_DIR)")
|
||||
p.add_argument("--log-backups", type=int,
|
||||
default=int(_env_default("HONEYPOT_LOG_BACKUPS", "0")),
|
||||
help="Rotated log segments to keep per file, rotating every "
|
||||
f"{LOG_MAX_BYTES // (1024 * 1024)} MB. 0 (the default) never "
|
||||
"rotates and lets the files grow (env HONEYPOT_LOG_BACKUPS)")
|
||||
p.add_argument("--banner", default=_env_default("HONEYPOT_BANNER", DEFAULT_BANNER),
|
||||
help="Spoofed SSH version string (env HONEYPOT_BANNER)")
|
||||
p.add_argument("--max-attempts", type=int,
|
||||
|
|
@ -372,7 +405,7 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|||
|
||||
def main(argv=None) -> int:
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.log_dir)
|
||||
setup_logging(args.log_dir, args.log_backups)
|
||||
|
||||
if not args.banner.startswith("SSH-2.0-"):
|
||||
log_event("config_error", error="banner must start with 'SSH-2.0-'")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue