feat(cloak): wire cloak into the deploy path for windows* deckies

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.
This commit is contained in:
2026-06-20 00:22:38 -04:00
parent f715ac6bcd
commit 402c1ef7a2
8 changed files with 258 additions and 13 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 ---

View File

@@ -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 <cloak ctx>/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}")

View File

@@ -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"]