diff --git a/decnet/archetypes.py b/decnet/archetypes.py index 2ccc6830..23cccf90 100644 --- a/decnet/archetypes.py +++ b/decnet/archetypes.py @@ -47,7 +47,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", + nmap_os="windows_server", ), "domain-controller": Archetype( slug="domain-controller", @@ -55,7 +55,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", + nmap_os="windows_server", ), "linux-server": Archetype( slug="linux-server", diff --git a/decnet/os_fingerprint.py b/decnet/os_fingerprint.py index 4fbb8f66..0c51b046 100644 --- a/decnet/os_fingerprint.py +++ b/decnet/os_fingerprint.py @@ -35,6 +35,8 @@ Windows (64240) from the kernel's default tcp_rmem settings. from __future__ import annotations +from dataclasses import dataclass + OS_SYSCTLS: dict[str, dict[str, str]] = { "linux": { "net.ipv4.ip_default_ttl": "64", @@ -49,9 +51,12 @@ OS_SYSCTLS: dict[str, dict[str, str]] = { "net.ipv4.icmp_ratemask": "6168", }, "windows": { + # Windows 10/11 workstation. NOTE: modern Windows runs TCP timestamps + # ON (nmap SEQ.TS=A) — an earlier value of 0 here fingerprinted as an + # ancient Windows/Linux stack. ECN off → nmap ECN.CC=N (workstation). "net.ipv4.ip_default_ttl": "128", "net.ipv4.tcp_syn_retries": "2", - "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_timestamps": "1", "net.ipv4.tcp_window_scaling": "1", "net.ipv4.tcp_sack": "1", "net.ipv4.tcp_ecn": "0", @@ -60,6 +65,22 @@ OS_SYSCTLS: dict[str, dict[str, str]] = { "net.ipv4.icmp_ratelimit": "0", "net.ipv4.icmp_ratemask": "0", }, + "windows_server": { + # Windows Server 2016/2019. Same NT stack as the workstation; the only + # stack-visible deltas nmap reads are ECN negotiated (CC=Y → tcp_ecn=1) + # and randomized IP-ID (SEQ.TI=RD, applied by the cloak mangler, not a + # sysctl). Everything else == "windows". + "net.ipv4.ip_default_ttl": "128", + "net.ipv4.tcp_syn_retries": "2", + "net.ipv4.tcp_timestamps": "1", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "1", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "30", + "net.ipv4.icmp_ratelimit": "0", + "net.ipv4.icmp_ratemask": "0", + }, "bsd": { "net.ipv4.ip_default_ttl": "64", "net.ipv4.tcp_syn_retries": "6", @@ -112,3 +133,47 @@ def all_os_families() -> list[str]: """Return all registered nmap OS family slugs.""" return list(OS_SYSCTLS.keys()) + +# ─── Egress mangle profiles (cloak) ────────────────────────────────────────── +# +# sysctls above reach only GLOBAL fields (TTL, timestamps on/off, ECN). The +# SYN-ACK *shape* nmap also scores — exact window, TCP option order, IP-ID +# generation — cannot be set per-container by sysctl. The cloak mangler +# (decnet/cloak) rewrites those on egress, driven by these profiles, keyed by +# the SAME nmap_os slug. A slug ABSENT here needs no mangling (its real Linux +# stack already approximates the target, e.g. "linux"/"bsd"). + + +@dataclass(frozen=True) +class MangleProfile: + """How the cloak rewrites a decky's egress to match an nmap_os family.""" + + window: int # TCP advertised window on SYN-ACK + mss: int # MSS option value + wscale: int # window-scale shift + # Ordered TCP option layout to emit on SYN-ACK. "TS" is kept only if the + # kernel emitted a Timestamp (sysctl tcp_timestamps=1) so its live, + # incrementing value survives the rewrite (nmap SEQ.TS rate test). + option_order: tuple[str, ...] + ipid: str # "incr" (TI=I) | "random" (TI=RD) | "keep" + respond_t2t3: bool # synthesize Windows T2/T3 replies + + +_WIN_OPTS = ("MSS", "NOP", "WScale", "SAckOK", "TS") + +OS_MANGLE: dict[str, MangleProfile] = { + "windows": MangleProfile( + window=0x2000, mss=1460, wscale=8, + option_order=_WIN_OPTS, ipid="incr", respond_t2t3=True, + ), + "windows_server": MangleProfile( + window=0x2000, mss=1460, wscale=8, + option_order=_WIN_OPTS, ipid="random", respond_t2t3=True, + ), +} + + +def get_os_mangle(nmap_os: str) -> MangleProfile | None: + """Return the cloak mangle profile for *nmap_os*, or None if it needs none.""" + return OS_MANGLE.get(nmap_os) + diff --git a/tests/core/test_os_fingerprint.py b/tests/core/test_os_fingerprint.py index 294a0cd5..eb1d22ce 100644 --- a/tests/core/test_os_fingerprint.py +++ b/tests/core/test_os_fingerprint.py @@ -50,8 +50,20 @@ def test_linux_tcp_timestamps_is_1(): assert get_os_sysctls("linux")["net.ipv4.tcp_timestamps"] == "1" -def test_windows_tcp_timestamps_is_0(): - assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "0" +def test_windows_tcp_timestamps_is_1(): + # Modern Windows 10/11 runs TCP timestamps ON (nmap SEQ.TS=A). A prior + # value of 0 here fingerprinted as an ancient stack — see os_fingerprint.py. + assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "1" + + +def test_windows_server_tcp_timestamps_is_1(): + assert get_os_sysctls("windows_server")["net.ipv4.tcp_timestamps"] == "1" + + +def test_windows_server_tcp_ecn_is_1(): + # Server negotiates ECN (nmap ECN.CC=Y); workstation does not (CC=N). + assert get_os_sysctls("windows_server")["net.ipv4.tcp_ecn"] == "1" + assert get_os_sysctls("windows")["net.ipv4.tcp_ecn"] == "0" def test_embedded_tcp_timestamps_is_0(): @@ -237,7 +249,7 @@ def test_all_os_families_non_empty(): assert "embedded" in families -@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"]) +@pytest.mark.parametrize("family", ["linux", "windows", "windows_server", "bsd", "embedded", "cisco"]) def test_all_os_profiles_have_required_sysctls(family: str): """Every OS profile must define the full canonical sysctl set.""" from decnet.os_fingerprint import _REQUIRED_SYSCTLS @@ -246,7 +258,7 @@ def test_all_os_profiles_have_required_sysctls(family: str): assert not missing, f"OS profile '{family}' is missing sysctls: {missing}" -@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"]) +@pytest.mark.parametrize("family", ["linux", "windows", "windows_server", "bsd", "embedded", "cisco"]) def test_all_os_sysctl_values_are_strings(family: str): """Docker Compose requires sysctl values to be strings, never ints.""" for _key, _val in get_os_sysctls(family).items(): @@ -267,9 +279,13 @@ def test_archetype_nmap_os_is_known(slug, arch): ) -@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" +def test_windows_workstation_archetype_nmap_os(): + assert ARCHETYPES["windows-workstation"].nmap_os == "windows" + + +@pytest.mark.parametrize("slug", ["windows-server", "domain-controller"]) +def test_windows_server_archetypes_use_server_nmap_os(slug): + assert ARCHETYPES[slug].nmap_os == "windows_server" @pytest.mark.parametrize("slug", ["printer", "iot-device", "industrial-control"]) @@ -403,11 +419,11 @@ def test_compose_linux_sysctls_include_timestamps(): assert sysctls.get("net.ipv4.tcp_timestamps") == "1" -def test_compose_windows_sysctls_no_timestamps(): - """Windows compose output must have tcp_timestamps disabled (= 0).""" +def test_compose_windows_sysctls_timestamps_on(): + """Windows compose output must have tcp_timestamps ENABLED (= 1) — Win10/11.""" compose = generate_compose(_make_config("windows")) sysctls = compose["services"]["decky-01"]["sysctls"] - assert sysctls.get("net.ipv4.tcp_timestamps") == "0" + assert sysctls.get("net.ipv4.tcp_timestamps") == "1" def test_compose_linux_sysctls_full_set():