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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -85,3 +85,6 @@ testfail
|
|||||||
# Internal design/dev notes — not for publication
|
# Internal design/dev notes — not for publication
|
||||||
/development/
|
/development/
|
||||||
decnet.tar
|
decnet.tar
|
||||||
|
|
||||||
|
# cloak base-image build context: decnet subtree synced in at deploy time
|
||||||
|
decnet/templates/_shared/cloak/decnet/
|
||||||
|
|||||||
@@ -35,17 +35,15 @@ def main() -> int:
|
|||||||
log.info("cloak: no mangle profile for %r — exiting", nmap_os)
|
log.info("cloak: no mangle profile for %r — exiting", nmap_os)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
threads = [
|
# Responder runs in a daemon thread; the mangler runs in the MAIN thread so
|
||||||
threading.Thread(target=mangler.run, args=(nmap_os,),
|
# its SIGTERM/SIGINT iptables-teardown handlers can be installed (signal only
|
||||||
name="cloak-mangler", daemon=True),
|
# works in the main thread).
|
||||||
threading.Thread(target=responder.run, args=(nmap_os, _open_ports()),
|
threading.Thread(
|
||||||
name="cloak-responder", daemon=True),
|
target=responder.run, args=(nmap_os, _open_ports()),
|
||||||
]
|
name="cloak-responder", daemon=True,
|
||||||
for t in threads:
|
).start()
|
||||||
t.start()
|
|
||||||
log.info("cloak: started for nmap_os=%r", nmap_os)
|
log.info("cloak: started for nmap_os=%r", nmap_os)
|
||||||
for t in threads:
|
mangler.run(nmap_os)
|
||||||
t.join()
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import subprocess # nosec B404 — fixed-arg iptables, no shell
|
import subprocess # nosec B404 — fixed-arg iptables, no shell
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from decnet.logging import get_logger
|
from decnet.logging import get_logger
|
||||||
@@ -120,8 +121,12 @@ def run(nmap_os: str) -> int:
|
|||||||
finally:
|
finally:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, _cleanup)
|
# signal.signal() only works in the main thread; the `finally` below still
|
||||||
signal.signal(signal.SIGINT, _cleanup)
|
# 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)",
|
log.info("cloak.mangler: rewriting SYN-ACK -> %s (window=%#x ipid=%s)",
|
||||||
nmap_os, profile.window, profile.ipid)
|
nmap_os, profile.window, profile.ipid)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import yaml
|
|||||||
|
|
||||||
from decnet.config import DecnetConfig
|
from decnet.config import DecnetConfig
|
||||||
from decnet.network import MACVLAN_NETWORK_NAME
|
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
|
from decnet.services.registry import get_service
|
||||||
|
|
||||||
_DOCKER_LOGGING = {
|
_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:
|
def generate_compose(config: DecnetConfig) -> dict:
|
||||||
"""Build and return the full docker-compose data structure."""
|
"""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["sysctls"] = get_os_sysctls(decky.nmap_os)
|
||||||
base["cap_add"] = ["NET_ADMIN"]
|
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
|
services[base_key] = base
|
||||||
|
|
||||||
# --- Service containers: share base network namespace ---
|
# --- Service containers: share base network namespace ---
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ _CANONICAL_NTLMSSP = Path(__file__).parent.parent / "templates" / "_shared" / "n
|
|||||||
_NTLMSSP_SERVICES = {"smb", "rdp"}
|
_NTLMSSP_SERVICES = {"smb", "rdp"}
|
||||||
_CANONICAL_CADDY_MODULES_DIR = Path(__file__).parent.parent / "templates" / "_caddy_modules"
|
_CANONICAL_CADDY_MODULES_DIR = Path(__file__).parent.parent / "templates" / "_caddy_modules"
|
||||||
_CADDY_SERVICES = {"http", "https"}
|
_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:
|
def _sync_logging_helper(config: DecnetConfig) -> None:
|
||||||
@@ -87,6 +101,26 @@ def _sync_logging_helper(config: DecnetConfig) -> None:
|
|||||||
shutil.copy2(src, dest)
|
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:
|
def _sync_auth_helper_sources(config: DecnetConfig) -> None:
|
||||||
"""Copy auth-helper.c into SSH/Telnet build contexts as auth-helper/.
|
"""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_auth_helper_sources(config)
|
||||||
_sync_ntlmssp_sources(config)
|
_sync_ntlmssp_sources(config)
|
||||||
_sync_caddy_modules(config)
|
_sync_caddy_modules(config)
|
||||||
|
_sync_cloak_sources(config)
|
||||||
|
|
||||||
compose_path = write_compose(config, COMPOSE_FILE)
|
compose_path = write_compose(config, COMPOSE_FILE)
|
||||||
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
|
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
|
||||||
|
|||||||
32
decnet/templates/_shared/cloak/Dockerfile
Normal file
32
decnet/templates/_shared/cloak/Dockerfile
Normal 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"]
|
||||||
@@ -40,6 +40,9 @@ dependencies = [
|
|||||||
# `alembic upgrade head` at boot for managed DBs (see db/migrate.py).
|
# `alembic upgrade head` at boot for managed DBs (see db/migrate.py).
|
||||||
"alembic>=1.13",
|
"alembic>=1.13",
|
||||||
"scapy>=2.6.1",
|
"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",
|
"orjson>=3.10",
|
||||||
"cryptography>=48.0.1",
|
"cryptography>=48.0.1",
|
||||||
"python-multipart>=0.0.31",
|
"python-multipart>=0.0.31",
|
||||||
|
|||||||
130
tests/cloak/test_compose_wiring.py
Normal file
130
tests/cloak/test_compose_wiring.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user