From 6610856749a98394296be13dbf37f73ce4173317 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 4 Apr 2026 13:19:06 -0300 Subject: [PATCH] Add nmap OS spoof per decky via TCP/IP stack sysctls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each decky base container now receives a set of Linux kernel sysctls (net.ipv4.ip_default_ttl, net.ipv4.tcp_syn_retries, etc.) tuned to match the claimed OS family, making nmap OS detection return the expected OS rather than the Linux host. - decnet/os_fingerprint.py: OS profile table (linux/windows/bsd/embedded/cisco) keyed by TTL and TCP tuning knobs - decnet/archetypes.py: Archetype gains nmap_os field; windows-* → "windows", printer/iot/industrial → "embedded", rest → "linux" - decnet/config.py: DeckyConfig gains nmap_os field (default "linux") - decnet/cli.py: nmap_os resolved from archetype → DeckyConfig in both CLI and INI build paths - decnet/composer.py: base container gets sysctls + cap_add: [NET_ADMIN]; service containers inherit via shared network namespace - tests/test_os_fingerprint.py: 48 new tests covering profiles, compose injection, archetype coverage, and CLI propagation Co-Authored-By: Claude Sonnet 4.6 --- decnet/archetypes.py | 15 +++ decnet/cli.py | 2 + decnet/composer.py | 8 ++ decnet/config.py | 1 + decnet/os_fingerprint.py | 55 ++++++++ tests/test_os_fingerprint.py | 248 +++++++++++++++++++++++++++++++++++ 6 files changed, 329 insertions(+) create mode 100644 decnet/os_fingerprint.py create mode 100644 tests/test_os_fingerprint.py diff --git a/decnet/archetypes.py b/decnet/archetypes.py index c117d21..d43b837 100644 --- a/decnet/archetypes.py +++ b/decnet/archetypes.py @@ -28,6 +28,7 @@ class Archetype: description: str services: list[str] # default service set for this machine type preferred_distros: list[str] # distro slugs to rotate through + nmap_os: str = "linux" # OS family slug for TCP/IP stack spoofing (see os_fingerprint.py) ARCHETYPES: dict[str, Archetype] = { @@ -37,6 +38,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Corporate Windows desktop: SMB shares + RDP access", services=["smb", "rdp"], preferred_distros=["debian", "ubuntu22"], + nmap_os="windows", ), "windows-server": Archetype( slug="windows-server", @@ -44,6 +46,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Windows domain member: SMB, RDP, and LDAP directory", services=["smb", "rdp", "ldap"], preferred_distros=["debian", "ubuntu22"], + nmap_os="windows", ), "domain-controller": Archetype( slug="domain-controller", @@ -51,6 +54,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Active Directory DC: LDAP, SMB, RDP, LLMNR", services=["ldap", "smb", "rdp", "llmnr"], preferred_distros=["debian", "ubuntu22"], + nmap_os="windows", ), "linux-server": Archetype( slug="linux-server", @@ -58,6 +62,7 @@ ARCHETYPES: dict[str, Archetype] = { description="General-purpose Linux host: SSH + HTTP", services=["ssh", "http"], preferred_distros=["debian", "ubuntu22", "rocky9", "fedora"], + nmap_os="linux", ), "web-server": Archetype( slug="web-server", @@ -65,6 +70,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Public-facing web host: HTTP + FTP", services=["http", "ftp"], preferred_distros=["debian", "ubuntu22", "ubuntu20"], + nmap_os="linux", ), "database-server": Archetype( slug="database-server", @@ -72,6 +78,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Data tier host: MySQL, PostgreSQL, Redis", services=["mysql", "postgres", "redis"], preferred_distros=["debian", "ubuntu22"], + nmap_os="linux", ), "mail-server": Archetype( slug="mail-server", @@ -79,6 +86,7 @@ ARCHETYPES: dict[str, Archetype] = { description="SMTP/IMAP/POP3 mail relay", services=["smtp", "pop3", "imap"], preferred_distros=["debian", "ubuntu22"], + nmap_os="linux", ), "file-server": Archetype( slug="file-server", @@ -86,6 +94,7 @@ ARCHETYPES: dict[str, Archetype] = { description="SMB/FTP/SFTP file storage node", services=["smb", "ftp", "ssh"], preferred_distros=["debian", "ubuntu22", "rocky9"], + nmap_os="linux", ), "printer": Archetype( slug="printer", @@ -93,6 +102,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Network-attached printer: SNMP + FTP", services=["snmp", "ftp"], preferred_distros=["alpine", "debian"], + nmap_os="embedded", ), "iot-device": Archetype( slug="iot-device", @@ -100,6 +110,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Embedded/IoT device: MQTT, SNMP, Telnet", services=["mqtt", "snmp", "telnet"], preferred_distros=["alpine"], + nmap_os="embedded", ), "industrial-control": Archetype( slug="industrial-control", @@ -107,6 +118,7 @@ ARCHETYPES: dict[str, Archetype] = { description="ICS/SCADA node: Conpot (Modbus/S7/DNP3) + SNMP", services=["conpot", "snmp"], preferred_distros=["debian"], + nmap_os="embedded", ), "voip-server": Archetype( slug="voip-server", @@ -114,6 +126,7 @@ ARCHETYPES: dict[str, Archetype] = { description="SIP PBX / VoIP gateway", services=["sip"], preferred_distros=["debian", "ubuntu22"], + nmap_os="linux", ), "monitoring-node": Archetype( slug="monitoring-node", @@ -121,6 +134,7 @@ ARCHETYPES: dict[str, Archetype] = { description="Infrastructure monitoring host: SNMP + SSH", services=["snmp", "ssh"], preferred_distros=["debian", "rocky9"], + nmap_os="linux", ), "devops-host": Archetype( slug="devops-host", @@ -128,6 +142,7 @@ ARCHETYPES: dict[str, Archetype] = { description="CI/CD or container host: Docker API + SSH + K8s", services=["docker_api", "ssh", "k8s"], preferred_distros=["ubuntu22", "debian"], + nmap_os="linux", ), } diff --git a/decnet/cli.py b/decnet/cli.py index d1b5ff2..97c4a8b 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -104,6 +104,7 @@ def _build_deckies( build_base=distro.build_base, hostname=hostname, archetype=archetype.slug if archetype else None, + nmap_os=archetype.nmap_os if archetype else "linux", ) ) return deckies @@ -188,6 +189,7 @@ def _build_deckies_from_ini( hostname=hostname, archetype=arch.slug if arch else None, service_config=spec.service_config, + nmap_os=arch.nmap_os if arch else "linux", )) return deckies diff --git a/decnet/composer.py b/decnet/composer.py index 08607bd..6e12dca 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -14,6 +14,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.services.registry import get_service _CONTAINER_LOG_DIR = "/var/log/decnet" @@ -63,6 +64,13 @@ def generate_compose(config: DecnetConfig) -> dict: } if config.log_target: base["networks"][_LOG_NETWORK] = {} + + # Inject TCP/IP stack sysctls to spoof the claimed OS fingerprint. + # Only the base container needs this — service containers inherit the + # same network namespace via network_mode: "service:". + base["sysctls"] = get_os_sysctls(decky.nmap_os) + base["cap_add"] = ["NET_ADMIN"] + services[base_key] = base # --- Service containers: share base network namespace --- diff --git a/decnet/config.py b/decnet/config.py index 336a62e..44cec70 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -28,6 +28,7 @@ class DeckyConfig(BaseModel): hostname: str archetype: str | None = None # archetype slug if spawned from an archetype profile service_config: dict[str, dict] = {} # optional per-service persona config + nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py) @field_validator("services") @classmethod diff --git a/decnet/os_fingerprint.py b/decnet/os_fingerprint.py new file mode 100644 index 0000000..38e1450 --- /dev/null +++ b/decnet/os_fingerprint.py @@ -0,0 +1,55 @@ +""" +OS TCP/IP fingerprint profiles for DECNET deckies. + +Maps an nmap OS family slug to a dict of Linux kernel sysctls that, when applied +to a container's network namespace, make its TCP/IP stack behaviour resemble the +claimed OS as closely as possible within the Linux kernel's constraints. + +Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL) + Linux → 64 + Windows → 128 + BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux) + Embedded / network → 255 + +Secondary tuning (TCP behaviour): + net.ipv4.tcp_syn_retries – SYN retransmits before giving up + net.core.rmem_default – initial receive buffer → affects SYN-ACK window field +""" + +from __future__ import annotations + +OS_SYSCTLS: dict[str, dict[str, str]] = { + "linux": { + "net.ipv4.ip_default_ttl": "64", + "net.ipv4.tcp_syn_retries": "6", + }, + "windows": { + "net.ipv4.ip_default_ttl": "128", + "net.ipv4.tcp_syn_retries": "2", + "net.core.rmem_default": "8388608", # 8 MB → large initial window like Windows + }, + "bsd": { + "net.ipv4.ip_default_ttl": "64", + "net.ipv4.tcp_syn_retries": "6", + }, + "embedded": { + "net.ipv4.ip_default_ttl": "255", + "net.ipv4.tcp_syn_retries": "3", + }, + "cisco": { + "net.ipv4.ip_default_ttl": "255", + "net.ipv4.tcp_syn_retries": "2", + }, +} + +_DEFAULT_OS = "linux" + + +def get_os_sysctls(nmap_os: str) -> dict[str, str]: + """Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs.""" + return dict(OS_SYSCTLS.get(nmap_os, OS_SYSCTLS[_DEFAULT_OS])) + + +def all_os_families() -> list[str]: + """Return all registered nmap OS family slugs.""" + return list(OS_SYSCTLS.keys()) diff --git a/tests/test_os_fingerprint.py b/tests/test_os_fingerprint.py new file mode 100644 index 0000000..4f31faa --- /dev/null +++ b/tests/test_os_fingerprint.py @@ -0,0 +1,248 @@ +""" +Tests for the OS TCP/IP fingerprint spoof feature. + +Covers: + - os_fingerprint.py: profiles, TTL values, fallback behaviour + - archetypes.py: every archetype has a valid nmap_os + - config.py: DeckyConfig carries nmap_os + - composer.py: base container gets sysctls + cap_add injected + - cli.py helpers: nmap_os propagated from archetype → DeckyConfig +""" + +import pytest + +from decnet.archetypes import ARCHETYPES, all_archetypes +from decnet.composer import generate_compose +from decnet.config import DeckyConfig, DecnetConfig +from decnet.os_fingerprint import OS_SYSCTLS, all_os_families, get_os_sysctls + + +# --------------------------------------------------------------------------- +# os_fingerprint module +# --------------------------------------------------------------------------- + +def test_linux_ttl_is_64(): + assert get_os_sysctls("linux")["net.ipv4.ip_default_ttl"] == "64" + + +def test_windows_ttl_is_128(): + assert get_os_sysctls("windows")["net.ipv4.ip_default_ttl"] == "128" + + +def test_embedded_ttl_is_255(): + assert get_os_sysctls("embedded")["net.ipv4.ip_default_ttl"] == "255" + + +def test_cisco_ttl_is_255(): + assert get_os_sysctls("cisco")["net.ipv4.ip_default_ttl"] == "255" + + +def test_bsd_ttl_is_64(): + assert get_os_sysctls("bsd")["net.ipv4.ip_default_ttl"] == "64" + + +def test_unknown_os_falls_back_to_linux(): + result = get_os_sysctls("nonexistent-os") + assert result == get_os_sysctls("linux") + + +def test_get_os_sysctls_returns_copy(): + """Mutating the returned dict must not alter the master profile.""" + s = get_os_sysctls("windows") + s["net.ipv4.ip_default_ttl"] = "999" + assert OS_SYSCTLS["windows"]["net.ipv4.ip_default_ttl"] == "128" + + +def test_all_os_families_non_empty(): + families = all_os_families() + assert len(families) > 0 + assert "linux" in families + assert "windows" in families + assert "embedded" in families + + +# --------------------------------------------------------------------------- +# Archetypes carry valid nmap_os values +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("slug,arch", list(ARCHETYPES.items())) +def test_archetype_nmap_os_is_known(slug, arch): + assert arch.nmap_os in all_os_families(), ( + f"Archetype '{slug}' has nmap_os='{arch.nmap_os}' which is not in OS_SYSCTLS" + ) + + +@pytest.mark.parametrize("slug", ["windows-workstation", "windows-server", "domain-controller"]) +def test_windows_archetypes_have_windows_nmap_os(slug): + assert ARCHETYPES[slug].nmap_os == "windows" + + +@pytest.mark.parametrize("slug", ["printer", "iot-device", "industrial-control"]) +def test_embedded_archetypes_have_embedded_nmap_os(slug): + assert ARCHETYPES[slug].nmap_os == "embedded" + + +@pytest.mark.parametrize("slug", ["linux-server", "web-server", "database-server", + "mail-server", "file-server", "voip-server", + "monitoring-node", "devops-host"]) +def test_linux_archetypes_have_linux_nmap_os(slug): + assert ARCHETYPES[slug].nmap_os == "linux" + + +# --------------------------------------------------------------------------- +# DeckyConfig default +# --------------------------------------------------------------------------- + +def _make_decky(nmap_os: str = "linux") -> DeckyConfig: + return DeckyConfig( + name="decky-01", + ip="10.0.0.10", + services=["ssh"], + distro="debian", + base_image="debian:bookworm-slim", + build_base="debian:bookworm-slim", + hostname="test-host", + nmap_os=nmap_os, + ) + + +def test_deckyconfig_default_nmap_os_is_linux(): + cfg = DeckyConfig( + name="decky-01", + ip="10.0.0.10", + services=["ssh"], + distro="debian", + base_image="debian:bookworm-slim", + build_base="debian:bookworm-slim", + hostname="test-host", + ) + assert cfg.nmap_os == "linux" + + +def test_deckyconfig_accepts_custom_nmap_os(): + cfg = _make_decky(nmap_os="windows") + assert cfg.nmap_os == "windows" + + +# --------------------------------------------------------------------------- +# Composer injects sysctls + cap_add into base container +# --------------------------------------------------------------------------- + +def _make_config(nmap_os: str = "linux") -> DecnetConfig: + return DecnetConfig( + mode="unihost", + interface="eth0", + subnet="10.0.0.0/24", + gateway="10.0.0.1", + deckies=[_make_decky(nmap_os=nmap_os)], + ) + + +def test_compose_base_has_sysctls(): + compose = generate_compose(_make_config("linux")) + base = compose["services"]["decky-01"] + assert "sysctls" in base + + +def test_compose_base_has_cap_net_admin(): + compose = generate_compose(_make_config("linux")) + base = compose["services"]["decky-01"] + assert "cap_add" in base + assert "NET_ADMIN" in base["cap_add"] + + +def test_compose_linux_ttl_64(): + compose = generate_compose(_make_config("linux")) + sysctls = compose["services"]["decky-01"]["sysctls"] + assert sysctls["net.ipv4.ip_default_ttl"] == "64" + + +def test_compose_windows_ttl_128(): + compose = generate_compose(_make_config("windows")) + sysctls = compose["services"]["decky-01"]["sysctls"] + assert sysctls["net.ipv4.ip_default_ttl"] == "128" + + +def test_compose_embedded_ttl_255(): + compose = generate_compose(_make_config("embedded")) + sysctls = compose["services"]["decky-01"]["sysctls"] + assert sysctls["net.ipv4.ip_default_ttl"] == "255" + + +def test_compose_service_containers_have_no_sysctls(): + """Service containers share the base network namespace — no sysctls needed there.""" + compose = generate_compose(_make_config("windows")) + svc = compose["services"]["decky-01-ssh"] + assert "sysctls" not in svc + + +def test_compose_two_deckies_independent_nmap_os(): + """Each decky gets its own OS profile.""" + decky_win = _make_decky(nmap_os="windows") + decky_lin = DeckyConfig( + name="decky-02", + ip="10.0.0.11", + services=["ssh"], + distro="debian", + base_image="debian:bookworm-slim", + build_base="debian:bookworm-slim", + hostname="test-host-2", + nmap_os="linux", + ) + config = DecnetConfig( + mode="unihost", + interface="eth0", + subnet="10.0.0.0/24", + gateway="10.0.0.1", + deckies=[decky_win, decky_lin], + ) + compose = generate_compose(config) + assert compose["services"]["decky-01"]["sysctls"]["net.ipv4.ip_default_ttl"] == "128" + assert compose["services"]["decky-02"]["sysctls"]["net.ipv4.ip_default_ttl"] == "64" + + +# --------------------------------------------------------------------------- +# CLI helper: nmap_os flows from archetype into DeckyConfig +# --------------------------------------------------------------------------- + +def test_build_deckies_windows_archetype_sets_nmap_os(): + from decnet.archetypes import get_archetype + from decnet.cli import _build_deckies + + arch = get_archetype("windows-workstation") + deckies = _build_deckies( + n=1, + ips=["10.0.0.20"], + services_explicit=None, + randomize_services=False, + archetype=arch, + ) + assert deckies[0].nmap_os == "windows" + + +def test_build_deckies_no_archetype_defaults_linux(): + from decnet.cli import _build_deckies + + deckies = _build_deckies( + n=1, + ips=["10.0.0.20"], + services_explicit=["ssh"], + randomize_services=False, + archetype=None, + ) + assert deckies[0].nmap_os == "linux" + + +def test_build_deckies_embedded_archetype_sets_nmap_os(): + from decnet.archetypes import get_archetype + from decnet.cli import _build_deckies + + arch = get_archetype("iot-device") + deckies = _build_deckies( + n=1, + ips=["10.0.0.20"], + services_explicit=None, + randomize_services=False, + archetype=arch, + ) + assert deckies[0].nmap_os == "embedded"