From 43f140a87a16989b7330f17049be7a8e1e596343 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 03:23:42 -0400 Subject: [PATCH] feat(cli): auto-spawn forwarder as detached sibling from decnet agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New _spawn_detached(argv, pid_file) helper uses Popen with start_new_session=True + close_fds=True + DEVNULL stdio to launch a DECNET subcommand as a fully independent process. The parent does NOT wait(); if it dies the child survives under init. This is deliberately not a supervisor — if the child dies the operator restarts it manually. _pid_dir() picks /opt/decnet when writable else ~/.decnet, so both root-run production and non-root dev work without ceremony. `decnet agent` now auto-spawns `decnet forwarder --daemon ...` as that detached sibling, pulling master host + syslog port from DECNET_SWARM_MASTER_HOST / DECNET_SWARM_SYSLOG_PORT. --no-forwarder opts out. If DECNET_SWARM_MASTER_HOST is unset the auto-spawn is silently skipped (single-host dev or operator wants to start the forwarder separately). tests/test_auto_spawn.py monkeypatches subprocess.Popen and verifies: the detach kwargs are passed, the PID file exists and contains a valid positive integer (PID-file corruption is a real operational headache — catching bad writes at the test level is free), the --no-forwarder flag suppresses the spawn, and the unset-master-host path silently skips. --- decnet/cli.py | 82 +++++++++++++++++++++++- tests/test_auto_spawn.py | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/test_auto_spawn.py diff --git a/decnet/cli.py b/decnet/cli.py index e490a00..f7d784d 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -9,6 +9,7 @@ Usage: """ import signal +from pathlib import Path from typing import Optional import typer @@ -51,6 +52,51 @@ def _daemonize() -> None: sys.stdin = open(os.devnull, "r") # noqa: SIM115 +def _pid_dir() -> Path: + """Return the writable PID directory. + + /opt/decnet when it exists and is writable (production), else + ~/.decnet (dev). The directory is created if needed.""" + import os + candidates = [Path("/opt/decnet"), Path.home() / ".decnet"] + for path in candidates: + try: + path.mkdir(parents=True, exist_ok=True) + if os.access(path, os.W_OK): + return path + except (PermissionError, OSError): + continue + # Last-resort fallback so we never raise from a helper. + return Path("/tmp") # nosec B108 + + +def _spawn_detached(argv: list[str], pid_file: Path) -> int: + """Spawn a DECNET subcommand as a fully-independent sibling process. + + The parent does NOT wait() on this child. start_new_session=True puts + the child in its own session so SIGHUP on parent exit doesn't kill it; + stdin/stdout/stderr go to /dev/null so the launching shell can close + without EIO on the child. close_fds=True prevents inherited sockets + from pinning ports we're trying to rebind. + + This is deliberately NOT a supervisor — we fire-and-forget. If the + child dies, the operator restarts it manually via its own subcommand + (e.g. `decnet forwarder --daemon …`). Detached means detached. + """ + import os + import subprocess # nosec B404 + + with open(os.devnull, "rb") as dn_in, open(os.devnull, "ab") as dn_out: + proc = subprocess.Popen( # nosec B603 + argv, + stdin=dn_in, stdout=dn_out, stderr=dn_out, + start_new_session=True, close_fds=True, + ) + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(f"{proc.pid}\n") + return proc.pid + + app = typer.Typer( name="decnet", help="Deploy a deception network of honeypot deckies on your LAN.", @@ -170,10 +216,21 @@ def agent( host: str = typer.Option("0.0.0.0", "--host", help="Bind address for the worker agent"), # nosec B104 agent_dir: Optional[str] = typer.Option(None, "--agent-dir", help="Worker cert bundle dir (default: ~/.decnet/agent, expanded under the running user's HOME — set this when running as sudo/root)"), daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"), + no_forwarder: bool = typer.Option(False, "--no-forwarder", help="Do not auto-spawn the log forwarder alongside the agent"), ) -> None: - """Run the DECNET SWARM worker agent (requires a cert bundle in ~/.decnet/agent/).""" + """Run the DECNET SWARM worker agent (requires a cert bundle in ~/.decnet/agent/). + + By default, `decnet agent` auto-spawns `decnet forwarder` as a fully- + detached sibling process so worker logs start flowing to the master + without a second manual invocation. The forwarder survives agent + restarts and crashes — if it dies on its own, restart it manually + with `decnet forwarder --daemon …`. Pass --no-forwarder to skip. + """ + import os import pathlib as _pathlib + import sys as _sys from decnet.agent import server as _agent_server + from decnet.env import DECNET_SWARM_MASTER_HOST, DECNET_INGEST_LOG_FILE from decnet.swarm import pki as _pki resolved_dir = _pathlib.Path(agent_dir) if agent_dir else _pki.DEFAULT_AGENT_DIR @@ -182,6 +239,29 @@ def agent( log.info("agent daemonizing host=%s port=%d", host, port) _daemonize() + # Auto-spawn the forwarder as a detached sibling BEFORE blocking on the + # agent server. Requires DECNET_SWARM_MASTER_HOST — if unset, the + # auto-spawn is silently skipped (single-host dev, or operator plans to + # start the forwarder separately). + if not no_forwarder and DECNET_SWARM_MASTER_HOST: + fw_argv = [ + _sys.executable, "-m", "decnet", "forwarder", + "--master-host", DECNET_SWARM_MASTER_HOST, + "--master-port", str(int(os.environ.get("DECNET_SWARM_SYSLOG_PORT", "6514"))), + "--agent-dir", str(resolved_dir), + "--log-file", str(DECNET_INGEST_LOG_FILE), + "--daemon", + ] + try: + pid = _spawn_detached(fw_argv, _pid_dir() / "forwarder.pid") + log.info("agent auto-spawned forwarder pid=%d master=%s", pid, DECNET_SWARM_MASTER_HOST) + console.print(f"[dim]Auto-spawned forwarder (pid {pid}) → {DECNET_SWARM_MASTER_HOST}.[/]") + except Exception as e: # noqa: BLE001 + log.warning("agent could not auto-spawn forwarder: %s", e) + console.print(f"[yellow]forwarder auto-spawn skipped: {e}[/]") + elif not no_forwarder: + log.info("agent skipping forwarder auto-spawn (DECNET_SWARM_MASTER_HOST unset)") + log.info("agent command invoked host=%s port=%d dir=%s", host, port, resolved_dir) console.print(f"[green]Starting DECNET worker agent on {host}:{port} (mTLS)...[/]") rc = _agent_server.run(host, port, agent_dir=resolved_dir) diff --git a/tests/test_auto_spawn.py b/tests/test_auto_spawn.py new file mode 100644 index 0000000..f6ff675 --- /dev/null +++ b/tests/test_auto_spawn.py @@ -0,0 +1,135 @@ +"""Auto-spawn of forwarder from `decnet agent` (and listener from +`decnet swarmctl`, added in a later patch). + +These tests monkeypatch subprocess.Popen inside decnet.cli so no real +process is ever forked. We assert on the Popen call shape — argv, +start_new_session, stdio redirection — plus PID-file correctness. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + + +class _FakePopen: + """Minimal Popen stub. Records the call; reports a fake PID.""" + last_instance: "None | _FakePopen" = None + + def __init__(self, argv: list[str], **kwargs: Any) -> None: + self.argv = argv + self.kwargs = kwargs + self.pid = 424242 + _FakePopen.last_instance = self + + +@pytest.fixture +def fake_popen(monkeypatch): + import decnet.cli as cli_mod + # Patch the subprocess module _spawn_detached reaches via its local + # import. Easier: patch subprocess.Popen globally in the subprocess + # module, since _spawn_detached uses `import subprocess` locally. + import subprocess + monkeypatch.setattr(subprocess, "Popen", _FakePopen) + _FakePopen.last_instance = None + return cli_mod + + +def test_spawn_detached_sets_new_session_and_writes_pid(fake_popen, tmp_path): + pid_file = tmp_path / "forwarder.pid" + pid = fake_popen._spawn_detached( + ["/usr/bin/true", "--flag"], pid_file, + ) + # The helper returns the pid from the Popen instance. + assert pid == 424242 + # PID file exists and contains a valid positive integer. + raw = pid_file.read_text().strip() + assert raw.isdigit(), f"PID file not numeric: {raw!r}" + assert int(raw) > 0, "PID file must contain a positive integer" + assert int(raw) == pid + # Detach flags were passed. + call = _FakePopen.last_instance + assert call is not None + assert call.kwargs["start_new_session"] is True + assert call.kwargs["close_fds"] is True + # stdin/stdout/stderr were redirected (file handles, not None). + assert call.kwargs["stdin"] is not None + assert call.kwargs["stdout"] is not None + assert call.kwargs["stderr"] is not None + + +def test_pid_file_parent_is_created(fake_popen, tmp_path): + nested = tmp_path / "run" / "decnet" / "forwarder.pid" + assert not nested.parent.exists() + fake_popen._spawn_detached(["/usr/bin/true"], nested) + assert nested.exists() + assert int(nested.read_text().strip()) > 0 + + +def test_agent_autospawns_forwarder(fake_popen, monkeypatch, tmp_path): + """`decnet agent` calls _spawn_detached once with a forwarder argv.""" + # Isolate PID dir to tmp_path so the test doesn't touch /opt/decnet. + monkeypatch.setattr(fake_popen, "_pid_dir", lambda: tmp_path) + # Set master host so the auto-spawn branch fires. + monkeypatch.setenv("DECNET_SWARM_MASTER_HOST", "10.0.0.1") + monkeypatch.setenv("DECNET_SWARM_SYSLOG_PORT", "6514") + # Stub the actual agent server so the command body returns fast. + from decnet.agent import server as _agent_server + monkeypatch.setattr(_agent_server, "run", lambda *a, **k: 0) + + # We also need to re-read DECNET_SWARM_MASTER_HOST through env.py at + # call time. env.py already read it at import, so patch on the module. + from decnet import env as _env + monkeypatch.setattr(_env, "DECNET_SWARM_MASTER_HOST", "10.0.0.1") + + from typer.testing import CliRunner + runner = CliRunner() + # Invoke the agent command directly (without --daemon to avoid + # double-forking the pytest worker). + result = runner.invoke(fake_popen.app, ["agent", "--port", "8765"]) + # Agent server was stubbed → exit=0; the important thing is the Popen + # got called with a forwarder argv. + assert result.exit_code == 0, result.stdout + call = _FakePopen.last_instance + assert call is not None, "expected _spawn_detached → Popen to fire" + assert "forwarder" in call.argv + assert "--master-host" in call.argv + assert "10.0.0.1" in call.argv + assert "--daemon" in call.argv + # PID file was written in the test tmpdir, not /opt/decnet. + assert (tmp_path / "forwarder.pid").exists() + + +def test_agent_no_forwarder_flag_suppresses_spawn(fake_popen, monkeypatch, tmp_path): + monkeypatch.setattr(fake_popen, "_pid_dir", lambda: tmp_path) + monkeypatch.setenv("DECNET_SWARM_MASTER_HOST", "10.0.0.1") + from decnet.agent import server as _agent_server + monkeypatch.setattr(_agent_server, "run", lambda *a, **k: 0) + from decnet import env as _env + monkeypatch.setattr(_env, "DECNET_SWARM_MASTER_HOST", "10.0.0.1") + + from typer.testing import CliRunner + runner = CliRunner() + result = runner.invoke(fake_popen.app, ["agent", "--no-forwarder"]) + assert result.exit_code == 0, result.stdout + assert _FakePopen.last_instance is None, "forwarder should NOT have been spawned" + assert not (tmp_path / "forwarder.pid").exists() + + +def test_agent_skips_forwarder_when_master_unset(fake_popen, monkeypatch, tmp_path): + """If DECNET_SWARM_MASTER_HOST is not set, auto-spawn is silently + skipped — we don't know where to ship logs to.""" + monkeypatch.setattr(fake_popen, "_pid_dir", lambda: tmp_path) + monkeypatch.delenv("DECNET_SWARM_MASTER_HOST", raising=False) + from decnet.agent import server as _agent_server + monkeypatch.setattr(_agent_server, "run", lambda *a, **k: 0) + from decnet import env as _env + monkeypatch.setattr(_env, "DECNET_SWARM_MASTER_HOST", None) + + from typer.testing import CliRunner + runner = CliRunner() + result = runner.invoke(fake_popen.app, ["agent"]) + assert result.exit_code == 0 + assert _FakePopen.last_instance is None