fix(collector): daemonize background subprocesses with start_new_session

Collector and mutator watcher subprocesses were spawned without
start_new_session=True, leaving them in the parent's process group.
SIGHUP (sent when the controlling terminal closes) killed both
processes silently — stdout/stderr were DEVNULL so the crash was
invisible.

Also update test_services and test_composer to reflect the ssh plugin
no longer using Cowrie env vars (replaced with SSH_ROOT_PASSWORD /
SSH_HOSTNAME matching the real_ssh plugin).
This commit is contained in:
2026-04-11 19:36:46 -04:00
parent d4ac53c0c9
commit a6063efbb9
3 changed files with 31 additions and 32 deletions

View File

@@ -390,7 +390,8 @@ def deploy(
subprocess.Popen( # nosec B603 subprocess.Popen( # nosec B603
[sys.executable, "-m", "decnet.cli", "mutate", "--watch"], [sys.executable, "-m", "decnet.cli", "mutate", "--watch"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT stderr=subprocess.STDOUT,
start_new_session=True,
) )
except (FileNotFoundError, subprocess.SubprocessError): except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start mutator watcher.[/]") console.print("[red]Failed to start mutator watcher.[/]")
@@ -405,6 +406,7 @@ def deploy(
[sys.executable, "-m", "decnet.cli", "collect", "--log-file", str(effective_log_file)], [sys.executable, "-m", "decnet.cli", "collect", "--log-file", str(effective_log_file)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
start_new_session=True,
) )
if api and not dry_run: if api and not dry_run:

View File

@@ -121,8 +121,8 @@ def test_service_config_http_server_header():
assert env.get("SERVER_HEADER") == "nginx/1.18.0" assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_service_config_ssh_kernel_version(): def test_service_config_ssh_password():
"""service_config for ssh must inject COWRIE_HONEYPOT_KERNEL_VERSION.""" """service_config for ssh must inject SSH_ROOT_PASSWORD."""
from decnet.config import DeckyConfig, DecnetConfig from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS from decnet.distros import DISTROS
profile = DISTROS["debian"] profile = DISTROS["debian"]
@@ -131,7 +131,7 @@ def test_service_config_ssh_kernel_version():
services=["ssh"], distro="debian", services=["ssh"], distro="debian",
base_image=profile.image, build_base=profile.build_base, base_image=profile.image, build_base=profile.build_base,
hostname="test-host", hostname="test-host",
service_config={"ssh": {"kernel_version": "5.15.0-76-generic"}}, service_config={"ssh": {"password": "s3cr3t!"}},
) )
config = DecnetConfig( config = DecnetConfig(
mode="unihost", interface="eth0", mode="unihost", interface="eth0",
@@ -140,7 +140,8 @@ def test_service_config_ssh_kernel_version():
) )
compose = generate_compose(config) compose = generate_compose(config)
env = compose["services"]["decky-01-ssh"]["environment"] env = compose["services"]["decky-01-ssh"]["environment"]
assert env.get("COWRIE_HONEYPOT_KERNEL_VERSION") == "5.15.0-76-generic" assert env.get("SSH_ROOT_PASSWORD") == "s3cr3t!"
assert not any(k.startswith("COWRIE_") for k in env)
def test_service_config_for_one_service_does_not_affect_another(): def test_service_config_for_one_service_does_not_affect_another():

View File

@@ -40,7 +40,7 @@ UPSTREAM_SERVICES = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
BUILD_SERVICES = { BUILD_SERVICES = {
"ssh": ([22, 2222], "ssh"), "ssh": ([22], "ssh"),
"http": ([80, 443], "http"), "http": ([80, 443], "http"),
"rdp": ([3389], "rdp"), "rdp": ([3389], "rdp"),
"smb": ([445, 139], "smb"), "smb": ([445, 139], "smb"),
@@ -155,7 +155,10 @@ def test_build_service_restart_policy(name):
assert frag.get("restart") == "unless-stopped" assert frag.get("restart") == "unless-stopped"
@pytest.mark.parametrize("name", BUILD_SERVICES) _NODE_NAME_SERVICES = [n for n in BUILD_SERVICES if n not in ("ssh", "real_ssh")]
@pytest.mark.parametrize("name", _NODE_NAME_SERVICES)
def test_build_service_node_name_env(name): def test_build_service_node_name_env(name):
frag = _fragment(name) frag = _fragment(name)
env = frag.get("environment", {}) env = frag.get("environment", {})
@@ -163,8 +166,8 @@ def test_build_service_node_name_env(name):
assert env["NODE_NAME"] == "test-decky" assert env["NODE_NAME"] == "test-decky"
# SSH uses COWRIE_OUTPUT_TCP_* instead of LOG_TARGET — exclude from generic tests # ssh and real_ssh do not use LOG_TARGET (rsyslog handles log forwarding inside the container)
_LOG_TARGET_SERVICES = [n for n in BUILD_SERVICES if n != "ssh"] _LOG_TARGET_SERVICES = [n for n in BUILD_SERVICES if n not in ("ssh", "real_ssh")]
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES) @pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
@@ -181,13 +184,11 @@ def test_build_service_no_log_target_by_default(name):
assert "LOG_TARGET" not in env assert "LOG_TARGET" not in env
def test_ssh_log_target_uses_cowrie_tcp_output(): def test_ssh_no_log_target_env():
"""SSH forwards logs via Cowrie TCP output, not LOG_TARGET.""" """SSH uses rsyslog internally — no LOG_TARGET or COWRIE_* vars."""
env = _fragment("ssh", log_target="10.0.0.1:5140").get("environment", {}) env = _fragment("ssh", log_target="10.0.0.1:5140").get("environment", {})
assert env.get("COWRIE_OUTPUT_TCP_ENABLED") == "true"
assert env.get("COWRIE_OUTPUT_TCP_HOST") == "10.0.0.1"
assert env.get("COWRIE_OUTPUT_TCP_PORT") == "5140"
assert "LOG_TARGET" not in env assert "LOG_TARGET" not in env
assert not any(k.startswith("COWRIE_") for k in env)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -266,31 +267,26 @@ def test_http_empty_service_cfg_no_extra_env():
# SSH ------------------------------------------------------------------------ # SSH ------------------------------------------------------------------------
def test_ssh_default_no_persona_env(): def test_ssh_default_env():
env = _fragment("ssh").get("environment", {}) env = _fragment("ssh").get("environment", {})
for key in ("COWRIE_HONEYPOT_KERNEL_VERSION", "COWRIE_HONEYPOT_HARDWARE_PLATFORM", assert env.get("SSH_ROOT_PASSWORD") == "admin"
"COWRIE_SSH_VERSION", "COWRIE_USERDB_ENTRIES"): assert not any(k.startswith("COWRIE_") for k in env)
assert key not in env, f"Expected {key} absent by default" assert "NODE_NAME" not in env
def test_ssh_kernel_version(): def test_ssh_custom_password():
env = _fragment("ssh", service_cfg={"kernel_version": "5.15.0-76-generic"}).get("environment", {}) env = _fragment("ssh", service_cfg={"password": "h4x!"}).get("environment", {})
assert env.get("COWRIE_HONEYPOT_KERNEL_VERSION") == "5.15.0-76-generic" assert env.get("SSH_ROOT_PASSWORD") == "h4x!"
def test_ssh_hardware_platform(): def test_ssh_custom_hostname():
env = _fragment("ssh", service_cfg={"hardware_platform": "aarch64"}).get("environment", {}) env = _fragment("ssh", service_cfg={"hostname": "prod-db"}).get("environment", {})
assert env.get("COWRIE_HONEYPOT_HARDWARE_PLATFORM") == "aarch64" assert env.get("SSH_HOSTNAME") == "prod-db"
def test_ssh_banner(): def test_ssh_no_hostname_by_default():
env = _fragment("ssh", service_cfg={"ssh_banner": "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.3"}).get("environment", {}) env = _fragment("ssh").get("environment", {})
assert env.get("COWRIE_SSH_VERSION") == "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.3" assert "SSH_HOSTNAME" not in env
def test_ssh_users():
env = _fragment("ssh", service_cfg={"users": "root:toor,admin:admin123"}).get("environment", {})
assert env.get("COWRIE_USERDB_ENTRIES") == "root:toor,admin:admin123"
# SMTP ----------------------------------------------------------------------- # SMTP -----------------------------------------------------------------------