changing default for log rotation

This commit is contained in:
Caffeine Fueled 2026-08-06 21:39:52 +02:00
parent 1fd4b9f7c1
commit 45efdf1524
Signed by: cf7
GPG key ID: CA295D643074C68C

View file

@ -38,23 +38,43 @@ DEFAULT_BANNER = "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4"
_event_logger = logging.getLogger("honeypot.events") # -> human-readable _event_logger = logging.getLogger("honeypot.events") # -> human-readable
_json_logger = logging.getLogger("honeypot.json") # -> JSON Lines _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. """Configure three sinks: JSON Lines file, text file, and stdout.
Both ``_event_logger`` and ``_json_logger`` are configured here. They do Both ``_event_logger`` and ``_json_logger`` are configured here. They do
not propagate to the root logger so paramiko's transport chatter stays out not propagate to the root logger so paramiko's transport chatter stays out
of our event logs. 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) os.makedirs(log_dir, exist_ok=True)
# JSON Lines: one json.dumps() record per line, no extra formatting. # JSON Lines: one json.dumps() record per line, no extra formatting.
json_handler = RotatingFileHandler( json_handler = _log_handler(os.path.join(log_dir, "honeypot.jsonl"), backup_count)
os.path.join(log_dir, "honeypot.jsonl"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
json_handler.setFormatter(logging.Formatter("%(message)s")) json_handler.setFormatter(logging.Formatter("%(message)s"))
_json_logger.addHandler(json_handler) _json_logger.addHandler(json_handler)
_json_logger.setLevel(logging.INFO) _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. # 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_fmt = logging.Formatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S")
text_handler = RotatingFileHandler( text_handler = _log_handler(os.path.join(log_dir, "honeypot.log"), backup_count)
os.path.join(log_dir, "honeypot.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
text_handler.setFormatter(text_fmt) text_handler.setFormatter(text_fmt)
stdout_handler = logging.StreamHandler() # defaults to stderr/stdout stream 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.setLevel(logging.INFO)
_event_logger.propagate = False _event_logger.propagate = False
# Keep paramiko's internal logging quiet but capture genuine errors. # Silence paramiko's own logging. An internet-facing honeypot constantly
logging.getLogger("paramiko").setLevel(logging.WARNING) # 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: 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 fields that matter) instead of a generic key=value dump, so a stream of
attempts is easy to read at a glance. 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 event == "login_attempt":
if fields.get("auth_method") == "password": 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} " cred = (f"user={fields.get('username')!r} "
f"key={fields.get('key_type')}/{fields.get('key_fingerprint')}") f"key={fields.get('key_type')}/{fields.get('key_fingerprint')}")
client = _sanitize(fields.get("client_version", "")) 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": if event == "connection":
return f"{src:<21} new connection" return f"{src} new connection"
if event == "disconnect": if event == "disconnect":
return f"{src:<21} disconnected" return f"{src} disconnected"
if event == "connection_error": 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": if event == "server_start":
return (f"listening on {fields.get('bind')}:{fields.get('port')} " return (f"listening on {fields.get('bind')}:{fields.get('port')} "
f"banner={fields.get('banner')!r} fingerprint={fields.get('host_key_fingerprint')}") 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: 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: 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)") help="Path to the persisted host key (env HONEYPOT_HOST_KEY)")
p.add_argument("--log-dir", default=_env_default("HONEYPOT_LOG_DIR", "./logs"), p.add_argument("--log-dir", default=_env_default("HONEYPOT_LOG_DIR", "./logs"),
help="Directory for log files (env HONEYPOT_LOG_DIR)") 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), p.add_argument("--banner", default=_env_default("HONEYPOT_BANNER", DEFAULT_BANNER),
help="Spoofed SSH version string (env HONEYPOT_BANNER)") help="Spoofed SSH version string (env HONEYPOT_BANNER)")
p.add_argument("--max-attempts", type=int, p.add_argument("--max-attempts", type=int,
@ -372,7 +405,7 @@ def parse_args(argv=None) -> argparse.Namespace:
def main(argv=None) -> int: def main(argv=None) -> int:
args = parse_args(argv) 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-"): if not args.banner.startswith("SSH-2.0-"):
log_event("config_error", error="banner must start with 'SSH-2.0-'") log_event("config_error", error="banner must start with 'SSH-2.0-'")