Add nmap OS spoof per decky via TCP/IP stack sysctls

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:19:06 -03:00
parent bbb5fa7a7e
commit 6610856749
6 changed files with 329 additions and 0 deletions

View File

@@ -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",
),
}

View File

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

View File

@@ -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>".
base["sysctls"] = get_os_sysctls(decky.nmap_os)
base["cap_add"] = ["NET_ADMIN"]
services[base_key] = base
# --- Service containers: share base network namespace ---

View File

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

55
decnet/os_fingerprint.py Normal file
View File

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