From 402c1ef7a23205fdd312923e6a71418b0309f14e Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 20 Jun 2026 00:22:38 -0400 Subject: [PATCH] feat(cloak): wire cloak into the deploy path for windows* deckies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base containers whose nmap_os has a mangle profile now build the cloak image (FROM the per-decky distro), ship the light decnet subtree, and run 'python -m decnet.cloak' alongside holding the MACVLAN IP — netns-safe (cloak backgrounded behind 'exec sleep infinity' so a cloak crash never tears down the base/netns). composer injects build/command/NET_RAW/env (DECNET_NMAP_OS, DECNET_OPEN_PORTS, DECKY_IP); deployer._sync_cloak_sources syncs the subtree; non-windows deckies are unchanged. Mangler signal-guarded for thread use; entry runs mangler in main thread, responder as daemon. Verified live: real path makes nmap -O read 'Microsoft Windows Server 2012/2016' with handshakes intact. --- .gitignore | 3 + decnet/cloak/__main__.py | 18 ++- decnet/cloak/mangler.py | 9 +- decnet/composer.py | 41 ++++++- decnet/engine/deployer.py | 35 ++++++ decnet/templates/_shared/cloak/Dockerfile | 32 ++++++ pyproject.toml | 3 + tests/cloak/test_compose_wiring.py | 130 ++++++++++++++++++++++ 8 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 decnet/templates/_shared/cloak/Dockerfile create mode 100644 tests/cloak/test_compose_wiring.py diff --git a/.gitignore b/.gitignore index 3e729de4..7394b518 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ testfail # Internal design/dev notes — not for publication /development/ decnet.tar + +# cloak base-image build context: decnet subtree synced in at deploy time +decnet/templates/_shared/cloak/decnet/ diff --git a/decnet/cloak/__main__.py b/decnet/cloak/__main__.py index 00fd509d..80d06958 100644 --- a/decnet/cloak/__main__.py +++ b/decnet/cloak/__main__.py @@ -35,17 +35,15 @@ def main() -> int: log.info("cloak: no mangle profile for %r — exiting", nmap_os) return 0 - threads = [ - threading.Thread(target=mangler.run, args=(nmap_os,), - name="cloak-mangler", daemon=True), - threading.Thread(target=responder.run, args=(nmap_os, _open_ports()), - name="cloak-responder", daemon=True), - ] - for t in threads: - t.start() + # Responder runs in a daemon thread; the mangler runs in the MAIN thread so + # its SIGTERM/SIGINT iptables-teardown handlers can be installed (signal only + # works in the main thread). + threading.Thread( + target=responder.run, args=(nmap_os, _open_ports()), + name="cloak-responder", daemon=True, + ).start() log.info("cloak: started for nmap_os=%r", nmap_os) - for t in threads: - t.join() + mangler.run(nmap_os) return 0 diff --git a/decnet/cloak/mangler.py b/decnet/cloak/mangler.py index 50bb2b62..3d6b7000 100644 --- a/decnet/cloak/mangler.py +++ b/decnet/cloak/mangler.py @@ -18,6 +18,7 @@ import os import signal import subprocess # nosec B404 — fixed-arg iptables, no shell import sys +import threading from typing import Any from decnet.logging import get_logger @@ -120,8 +121,12 @@ def run(nmap_os: str) -> int: finally: sys.exit(0) - signal.signal(signal.SIGTERM, _cleanup) - signal.signal(signal.SIGINT, _cleanup) + # signal.signal() only works in the main thread; the `finally` below still + # removes the rule on a normal exit, and on container stop the netns (and + # its iptables rules) are torn down regardless. + if threading.current_thread() is threading.main_thread(): + signal.signal(signal.SIGTERM, _cleanup) + signal.signal(signal.SIGINT, _cleanup) log.info("cloak.mangler: rewriting SYN-ACK -> %s (window=%#x ipid=%s)", nmap_os, profile.window, profile.ipid) try: diff --git a/decnet/composer.py b/decnet/composer.py index 2a84ace7..bb4baf36 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -21,7 +21,7 @@ import yaml from decnet.config import DecnetConfig from decnet.network import MACVLAN_NETWORK_NAME -from decnet.os_fingerprint import get_os_sysctls +from decnet.os_fingerprint import get_os_mangle, get_os_sysctls from decnet.services.registry import get_service _DOCKER_LOGGING = { @@ -32,6 +32,26 @@ _DOCKER_LOGGING = { }, } +# Build context for the cloak base image (decnet subtree synced in by +# deployer._sync_cloak_sources before build). +_CLOAK_CONTEXT = Path(__file__).parent / "templates" / "_shared" / "cloak" + +# Netns-safe: run the cloak best-effort in the background, but keep `sleep +# infinity` as PID 1 in the foreground so a cloak crash never tears down the +# base container (and with it the netns every service container shares). +_CLOAK_COMMAND = ["sh", "-c", "python3 -m decnet.cloak & exec sleep infinity"] + + +def _decky_open_tcp_ports(services: list[str]) -> list[int]: + """Sorted, de-duped TCP ports a decky's services listen on (for the cloak + responder's T2/T3 classification — DECNET_OPEN_PORTS).""" + ports: set[int] = set() + for svc_name in services: + svc = get_service(svc_name) + if svc is not None: + ports.update(svc.ports) + return sorted(ports) + def generate_compose(config: DecnetConfig) -> dict: """Build and return the full docker-compose data structure.""" @@ -60,6 +80,25 @@ def generate_compose(config: DecnetConfig) -> dict: base["sysctls"] = get_os_sysctls(decky.nmap_os) base["cap_add"] = ["NET_ADMIN"] + # sysctls reach only global packet fields. nmap_os families with an + # egress mangle profile (windows*) additionally run the cloak in the + # base container to rewrite SYN-ACK shape + synthesize T2/T3 replies, so + # they read as the claimed OS under active fingerprinting (nmap -O). + if get_os_mangle(decky.nmap_os) is not None: + base.pop("image", None) + base["build"] = { + "context": str(_CLOAK_CONTEXT), + "args": {"BASE_IMAGE": decky.build_base}, + } + base["command"] = _CLOAK_COMMAND + base["cap_add"] = ["NET_ADMIN", "NET_RAW"] # NET_RAW: responder send/sniff + ports = _decky_open_tcp_ports(decky.services) + base["environment"] = { + "DECNET_NMAP_OS": decky.nmap_os, + "DECNET_OPEN_PORTS": ",".join(str(p) for p in ports), + "DECKY_IP": decky.ip, + } + services[base_key] = base # --- Service containers: share base network namespace --- diff --git a/decnet/engine/deployer.py b/decnet/engine/deployer.py index 884c18ca..8b220358 100644 --- a/decnet/engine/deployer.py +++ b/decnet/engine/deployer.py @@ -65,6 +65,20 @@ _CANONICAL_NTLMSSP = Path(__file__).parent.parent / "templates" / "_shared" / "n _NTLMSSP_SERVICES = {"smb", "rdp"} _CANONICAL_CADDY_MODULES_DIR = Path(__file__).parent.parent / "templates" / "_caddy_modules" _CADDY_SERVICES = {"http", "https"} +# Cloak base image: the decnet package root + the 8 light files shipped into the +# cloak build context so `python -m decnet.cloak` runs in the base container. +_DECNET_SRC = Path(__file__).parent.parent +_CANONICAL_CLOAK_DIR = _DECNET_SRC / "templates" / "_shared" / "cloak" +_CLOAK_SHIP_FILES = ( + "__init__.py", + "config_ini.py", + "logging/__init__.py", + "os_fingerprint.py", + "cloak/__init__.py", + "cloak/__main__.py", + "cloak/mangler.py", + "cloak/responder.py", +) def _sync_logging_helper(config: DecnetConfig) -> None: @@ -87,6 +101,26 @@ def _sync_logging_helper(config: DecnetConfig) -> None: shutil.copy2(src, dest) +def _sync_cloak_sources(config: DecnetConfig) -> None: + """Ship the light decnet subtree into the cloak base-image build context. + + Only when at least one decky has an egress mangle profile (windows*). Copies + the 8 files in _CLOAK_SHIP_FILES into /decnet/ preserving package + structure so the image's `python -m decnet.cloak` resolves. The dest tree is + gitignored. Mirrors the _sync_*_sources copy-if-changed idiom. + """ + from decnet.os_fingerprint import get_os_mangle + if not any(get_os_mangle(d.nmap_os) is not None for d in config.deckies): + return + dest_root = _CANONICAL_CLOAK_DIR / "decnet" + for rel in _CLOAK_SHIP_FILES: + src = _DECNET_SRC / rel + dest = dest_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + if not dest.exists() or dest.read_bytes() != src.read_bytes(): + shutil.copy2(src, dest) + + def _sync_auth_helper_sources(config: DecnetConfig) -> None: """Copy auth-helper.c into SSH/Telnet build contexts as auth-helper/. @@ -679,6 +713,7 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False, _sync_auth_helper_sources(config) _sync_ntlmssp_sources(config) _sync_caddy_modules(config) + _sync_cloak_sources(config) compose_path = write_compose(config, COMPOSE_FILE) console.print(f"[bold cyan]Compose file written[/] → {compose_path}") diff --git a/decnet/templates/_shared/cloak/Dockerfile b/decnet/templates/_shared/cloak/Dockerfile new file mode 100644 index 00000000..e148f6f6 --- /dev/null +++ b/decnet/templates/_shared/cloak/Dockerfile @@ -0,0 +1,32 @@ +# Cloak base image — the IP-holder/netns container for deckies whose nmap_os has +# an egress mangle profile (windows, windows_server). Runs `python -m decnet.cloak` +# (SYN-ACK mangler + T2/T3 responder) alongside holding the MACVLAN IP. +# +# FROM the per-decky distro so the base still varies by distro (BASE_IMAGE arg, +# set by the composer from decky.build_base — same pattern as service images). +# The decnet/ subtree is synced into this context by deployer._sync_cloak_sources +# before build (8 light, stdlib-only files; scapy/netfilterqueue are pip'd here). +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} + +# Runtime: iptables (NFQUEUE rules), python3, libpcap (scapy BPF sniff in the +# responder). Build-only: gcc + headers for the netfilterqueue C extension, +# purged after the wheel is built to keep the image lean. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip iptables libpcap0.8 \ + libnetfilter-queue1 libnfnetlink0 \ + gcc python3-dev libnetfilter-queue-dev libnfnetlink-dev \ + && pip3 install --no-cache-dir --break-system-packages \ + "scapy>=2.6.1" "netfilterqueue>=1.1.0" \ + && apt-get purge -y gcc python3-dev libnetfilter-queue-dev libnfnetlink-dev \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +# Synced 8-file decnet subtree (decnet/__init__, config_ini, logging/, os_fingerprint, +# cloak/). PYTHONPATH=/opt makes `python3 -m decnet.cloak` importable. +COPY decnet/ /opt/decnet/ +ENV PYTHONPATH=/opt + +# The compose `command` drives runtime (netns-safe supervisor: cloak in background, +# sleep infinity in foreground so a cloak crash never tears down the netns holder). +CMD ["sleep", "infinity"] diff --git a/pyproject.toml b/pyproject.toml index 9cd4e4d0..dab1421a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ dependencies = [ # `alembic upgrade head` at boot for managed DBs (see db/migrate.py). "alembic>=1.13", "scapy>=2.6.1", + # cloak egress mangler (NFQUEUE); Linux-only, lazy-imported so absence on + # dev/CI/non-Linux is tolerated (decnet.cloak only needs it at run()). + "netfilterqueue>=1.1.0 ; sys_platform == 'linux'", "orjson>=3.10", "cryptography>=48.0.1", "python-multipart>=0.0.31", diff --git a/tests/cloak/test_compose_wiring.py b/tests/cloak/test_compose_wiring.py new file mode 100644 index 00000000..edb445c7 --- /dev/null +++ b/tests/cloak/test_compose_wiring.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Tests for wiring the cloak into the deploy path: + - composer.py: windows* base containers get build+command+caps+env; non-mangled + bases stay byte-for-byte unchanged. + - composer._decky_open_tcp_ports: service-port enumeration. + - deployer._sync_cloak_sources: ships the decnet subtree only when needed. +""" +from __future__ import annotations + +import pytest + +from decnet.composer import _CLOAK_COMMAND, _decky_open_tcp_ports, generate_compose +from decnet.config import DeckyConfig, DecnetConfig + + +def _decky(nmap_os: str = "linux", services: list[str] | None = None) -> DeckyConfig: + return DeckyConfig( + name="decky-01", + ip="10.0.0.10", + services=services or ["ssh"], + distro="debian", + base_image="debian:bookworm-slim", + build_base="debian:bookworm-slim", + hostname="test-host", + nmap_os=nmap_os, + ) + + +def _config(decky: DeckyConfig) -> DecnetConfig: + return DecnetConfig( + mode="unihost", interface="eth0", subnet="10.0.0.0/24", + gateway="10.0.0.1", deckies=[decky], + ) + + +def _base(nmap_os: str, services: list[str] | None = None) -> dict: + return generate_compose(_config(_decky(nmap_os, services)))["services"]["decky-01"] + + +# ── port enumeration ──────────────────────────────────────────────────────── + +def test_open_ports_union_sorted_deduped(): + # smb=[445,139], rdp=[3389] + assert _decky_open_tcp_ports(["smb", "rdp"]) == [139, 445, 3389] + + +def test_open_ports_single_and_multiport(): + assert _decky_open_tcp_ports(["ssh"]) == [22] + assert _decky_open_tcp_ports(["imap"]) == [143, 993] # multi-port service + + +# ── non-mangled base is unchanged ─────────────────────────────────────────── + +def test_linux_base_uses_stock_image_and_sleep(): + base = _base("linux") + assert base["image"] == "debian:bookworm-slim" + assert base["command"] == ["sleep", "infinity"] + assert "build" not in base + assert "environment" not in base + assert base["cap_add"] == ["NET_ADMIN"] + + +@pytest.mark.parametrize("fam", ["embedded", "bsd", "cisco"]) +def test_other_families_not_cloaked(fam): + base = _base(fam) + assert "build" not in base + assert base["command"] == ["sleep", "infinity"] + assert "NET_RAW" not in base["cap_add"] + + +# ── windows* base gets the cloak ──────────────────────────────────────────── + +@pytest.mark.parametrize("fam", ["windows", "windows_server"]) +def test_windows_base_is_built_cloak_image(fam): + base = _base(fam, services=["smb", "rdp"]) + assert "image" not in base + assert base["build"]["args"]["BASE_IMAGE"] == "debian:bookworm-slim" + assert base["build"]["context"].endswith("templates/_shared/cloak") + + +@pytest.mark.parametrize("fam", ["windows", "windows_server"]) +def test_windows_base_runs_cloak_netns_safe(fam): + base = _base(fam) + # supervisor keeps sleep infinity as PID1 so a cloak crash can't kill the netns + assert base["command"] == _CLOAK_COMMAND + assert "decnet.cloak" in base["command"][-1] + assert "sleep infinity" in base["command"][-1] + + +@pytest.mark.parametrize("fam", ["windows", "windows_server"]) +def test_windows_base_caps_include_net_raw(fam): + base = _base(fam) + assert "NET_ADMIN" in base["cap_add"] + assert "NET_RAW" in base["cap_add"] + + +def test_windows_base_env_carries_profile_and_ports(): + base = _base("windows_server", services=["smb", "rdp"]) + env = base["environment"] + assert env["DECNET_NMAP_OS"] == "windows_server" + assert env["DECNET_OPEN_PORTS"] == "139,445,3389" + assert env["DECKY_IP"] == "10.0.0.10" + + +def test_windows_base_still_has_sysctls(): + base = _base("windows") + assert base["sysctls"]["net.ipv4.ip_default_ttl"] == "128" + assert base["sysctls"]["net.ipv4.tcp_timestamps"] == "1" + + +# ── deployer sync gating ──────────────────────────────────────────────────── + +def test_sync_cloak_ships_subtree_only_when_needed(tmp_path, monkeypatch): + from decnet.engine import deployer + + dest_root = tmp_path / "cloak" + monkeypatch.setattr(deployer, "_CANONICAL_CLOAK_DIR", dest_root) + + # linux-only → no-op + deployer._sync_cloak_sources(_config(_decky("linux"))) + assert not (dest_root / "decnet").exists() + + # windows → ships the subtree, package structure preserved + deployer._sync_cloak_sources(_config(_decky("windows"))) + shipped = dest_root / "decnet" + assert (shipped / "__init__.py").is_file() + assert (shipped / "os_fingerprint.py").is_file() + assert (shipped / "cloak" / "mangler.py").is_file() + assert (shipped / "logging" / "__init__.py").is_file()