feat(cli): auto-spawn forwarder as detached sibling from decnet agent
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
135
tests/test_auto_spawn.py
Normal file
135
tests/test_auto_spawn.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user