From 673bc5b8191989e605ee6996a761b79a5b797967 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 27 Apr 2026 21:26:13 -0400 Subject: [PATCH] ops(init): ship logrotate config so /var/log/decnet can't fill the disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without rotation, the syslog listener and per-host collector grow /var/log/decnet/ without bound — a noisy attacker (or an active probe storm) fills the disk in hours on a small VPS. New deploy/logrotate.d/decnet caps at 7 daily rotations or 100 MiB, whichever comes first, and uses copytruncate because the ingester and forwarder hold the files open via Python and won't reopen on a rename rotation. Wire install / remove into `decnet init` and `decnet init --deinit` alongside the existing tmpfiles.d / polkit handling. --- decnet/cli/init.py | 35 +++++++++++++++++++++++++++++++++++ deploy/logrotate.d/decnet | 28 ++++++++++++++++++++++++++++ tests/cli/test_init.py | 27 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 deploy/logrotate.d/decnet diff --git a/decnet/cli/init.py b/decnet/cli/init.py index 88a1de47..bb52cb65 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -493,6 +493,27 @@ def _install_tmpfiles( return result +def _install_logrotate( + deploy: Path, logrotate_dir: Path, *, force: bool, dry_run: bool +) -> str: + """Drop the logrotate config into ``/etc/logrotate.d/decnet``. + + The ingester / forwarder hold the log files open via Python, so the + config uses ``copytruncate`` rather than rename+create. Without this + rule, /var/log/decnet/ grows without bound and a single noisy day of + attacker traffic fills the disk on a small VPS. Best-effort: a host + without logrotate installed (rare on systemd distros) still boots + fine — the operator just needs to wire their own rotation. + """ + src = deploy / "logrotate.d" / "decnet" + if not src.is_file(): + raise RuntimeError(f"missing logrotate config at {src}") + return _copy_if_changed( + src, logrotate_dir / src.name, + mode=0o644, force=force, dry_run=dry_run, + ) + + def register(app: typer.Typer) -> None: @app.command(name="init") def init_cmd( @@ -595,6 +616,7 @@ def register(app: typer.Typer) -> None: systemd_dir = pfx / "etc/systemd/system" polkit_dir = pfx / "etc/polkit-1/rules.d" tmpfiles_dir = pfx / "etc/tmpfiles.d" + logrotate_dir = pfx / "etc/logrotate.d" etc_decnet = pfx / "etc/decnet" if deinit: @@ -627,6 +649,13 @@ def register(app: typer.Typer) -> None: dry_run=dry_run, ), ) + _step( + "remove logrotate config", + lambda: _remove_file( + logrotate_dir / "decnet", + dry_run=dry_run, + ), + ) _step( "systemctl daemon-reload", lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], @@ -775,6 +804,12 @@ def register(app: typer.Typer) -> None: deploy, tmpfiles_dir, force=force, dry_run=dry_run, ), ) + _step( + "install logrotate config", + lambda: _install_logrotate( + deploy, logrotate_dir, force=force, dry_run=dry_run, + ), + ) _step( "systemctl daemon-reload", lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], diff --git a/deploy/logrotate.d/decnet b/deploy/logrotate.d/decnet new file mode 100644 index 00000000..79bf8416 --- /dev/null +++ b/deploy/logrotate.d/decnet @@ -0,0 +1,28 @@ +# /etc/logrotate.d/decnet — installed by `decnet init`. +# +# Without this, /var/log/decnet/ grows unbounded — the syslog listener writes +# every forwarded worker line, the collector tails every container's stdout, +# and a noisy attacker (or an active probe storm) can fill the disk in hours. +# Bound to 7 daily rotations + size cap so a single bad day doesn't run away. +# +# Files we rotate: +# - decnet.log: master ingest sink (DECNET_INGEST_LOG_FILE). +# - agent.log: per-worker collector sink (DECNET_AGENT_LOG_FILE). +# - *.log: any other component sink under /var/log/decnet/. +# +# `copytruncate` is required: the ingester / forwarder hold the file open via +# Python and would otherwise keep writing to the deleted inode after rotation. +# `notifempty` avoids spurious .1 files on quiet hosts. + +/var/log/decnet/*.log { + daily + rotate 7 + maxsize 100M + copytruncate + missingok + notifempty + compress + delaycompress + su decnet decnet + create 0640 decnet decnet +} diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 2e910bd0..f3f3803e 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -81,6 +81,10 @@ def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> Path: '// rule for {{ group }}\n' ) (deploy / "tmpfiles.d" / "decnet.conf").write_text("d /run/decnet\n") + (deploy / "logrotate.d").mkdir() + (deploy / "logrotate.d" / "decnet").write_text( + "/var/log/decnet/*.log {\n daily\n rotate 7\n copytruncate\n}\n" + ) monkeypatch.setattr(_init, "_deploy_root", lambda: deploy) return deploy @@ -168,6 +172,25 @@ def test_unit_files_are_installed_then_idempotent( assert "unit files up to date" in r2.output +def test_init_installs_logrotate_config( + monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, present_user_and_group: None, +) -> None: + """`decnet init` must drop /etc/logrotate.d/decnet so /var/log/decnet/ + can't fill the disk on a noisy honeypot day.""" + _seed_deploy(monkeypatch, tmp_path) + prefix = tmp_path / "root" + r = runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)]) + assert r.exit_code == 0, r.output + cfg = prefix / "etc/logrotate.d/decnet" + assert cfg.is_file(), "logrotate config should be installed" + body = cfg.read_text() + assert "copytruncate" in body, ( + "must use copytruncate; ingester holds the file open and won't " + "reopen on a rename rotation" + ) + + def test_init_writes_decnet_ini_not_config_ini( monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]], no_missing_tools: None, missing_user_and_group: None, @@ -354,6 +377,9 @@ def _seed_installed_state(prefix: Path) -> None: tmpfiles = prefix / "etc/tmpfiles.d" tmpfiles.mkdir(parents=True) (tmpfiles / "decnet.conf").write_text("d /run/decnet\n") + logrotate = prefix / "etc/logrotate.d" + logrotate.mkdir(parents=True) + (logrotate / "decnet").write_text("/var/log/decnet/*.log {}\n") etc_decnet = prefix / "etc/decnet" etc_decnet.mkdir(parents=True) (etc_decnet / "decnet.ini").write_text("[decnet]\n") @@ -382,6 +408,7 @@ def test_deinit_removes_units_polkit_tmpfiles_and_preserves_data( assert not (prefix / "etc/systemd/system/decnet.target").exists() assert not (prefix / "etc/polkit-1/rules.d/50-decnet-workers.rules").exists() assert not (prefix / "etc/tmpfiles.d/decnet.conf").exists() + assert not (prefix / "etc/logrotate.d/decnet").exists() assert not (prefix / "etc/decnet").exists() assert not (prefix / "opt/decnet").exists()