diff --git a/decnet/cli.py b/decnet/cli.py index 97c4a8b..c65a303 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -179,6 +179,8 @@ def _build_deckies_from_ini( ) raise typer.Exit(1) + # nmap_os priority: explicit INI key > archetype default > "linux" + resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux") deckies.append(DeckyConfig( name=spec.name, ip=ip, @@ -189,7 +191,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", + nmap_os=resolved_nmap_os, )) return deckies diff --git a/decnet/ini_loader.py b/decnet/ini_loader.py index a6e6e66..1ecb493 100644 --- a/decnet/ini_loader.py +++ b/decnet/ini_loader.py @@ -53,6 +53,7 @@ class DeckySpec: services: list[str] | None = None archetype: str | None = None service_config: dict[str, dict] = field(default_factory=dict) + nmap_os: str | None = None # explicit OS family override (linux/windows/bsd/embedded/cisco) @dataclass @@ -113,6 +114,7 @@ def load_ini(path: str | Path) -> IniConfig: svc_raw = s.get("services") services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None archetype = s.get("archetype") + nmap_os = s.get("nmap_os") or s.get("nmap-os") or None amount_raw = s.get("amount", "1") try: amount = int(amount_raw) @@ -123,7 +125,7 @@ def load_ini(path: str | Path) -> IniConfig: if amount == 1: cfg.deckies.append(DeckySpec( - name=section, ip=ip, services=services, archetype=archetype, + name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, )) else: # Expand into N deckies; explicit ip is ignored (can't share one IP) @@ -138,6 +140,7 @@ def load_ini(path: str | Path) -> IniConfig: ip=None, services=services, archetype=archetype, + nmap_os=nmap_os, )) # Second pass: collect per-service subsections [decky-name.service] diff --git a/test-full.ini b/test-full.ini new file mode 100644 index 0000000..a2ad3af --- /dev/null +++ b/test-full.ini @@ -0,0 +1,192 @@ +# DECNET Full Test Config +# Covers all 25 registered services across 10 role-themed deckies + archetype pool. +# Distros are auto-cycled for heterogeneity (9 profiles, round-robin). +# +# nmap_os controls the TCP/IP stack sysctls injected into each decky's base +# container so nmap OS detection returns the expected OS family: +# linux → TTL 64, syn_retries 6 +# windows → TTL 128, syn_retries 2, large recv buffer +# embedded → TTL 255, syn_retries 3 +# bsd → TTL 64, syn_retries 6 +# cisco → TTL 255, syn_retries 2 +# +# Usage: +# decnet deploy --config test-full.ini --dry-run +# sudo decnet deploy --config test-full.ini --log-target 192.168.1.200:5140 \ +# --log-file /var/log/decnet/decnet.log + +[general] +net = 192.168.1.0/24 +gw = 192.168.1.1 +interface = wlp6s0 +#log_target = 192.168.1.200:5140 + +# ── Archetype pool: 10 Windows workstations ─────────────────────────────────── +# archetype=windows-workstation already sets nmap_os=windows automatically. + +[windows-workstation] +archetype = windows-workstation +amount = 10 + + +# ── Web / Mail stack ────────────────────────────────────────────────────────── +# Looks like an internet-facing Linux mail + web host + +[decky-webmail] +ip = 192.168.1.110 +services = http, smtp, imap, pop3 +nmap_os = linux + +[decky-webmail.http] +server_header = Apache/2.4.54 (Debian) +response_code = 200 +fake_app = wordpress + +[decky-webmail.smtp] +smtp_banner = 220 mail.corp.local ESMTP Postfix (Debian/GNU) +smtp_mta = mail.corp.local + + +# ── File / Transfer services ────────────────────────────────────────────────── +# Presents as a Windows/Samba file server — TTL 128 seals the illusion. + +[decky-fileserv] +ip = 192.168.1.111 +services = smb, ftp, tftp +nmap_os = windows + +[decky-fileserv.smb] +workgroup = CORP +server_name = FILESERV01 +os_version = Windows Server 2019 + + +# ── LAMP-style database host ────────────────────────────────────────────────── + +[decky-dbsrv01] +ip = 192.168.1.112 +services = mysql, redis +nmap_os = linux + +[decky-dbsrv01.mysql] +mysql_version = 5.7.38-log +mysql_banner = MySQL Community Server + +[decky-dbsrv01.redis] +redis_version = 6.2.7 + + +# ── Modern stack databases ──────────────────────────────────────────────────── + +[decky-dbsrv02] +ip = 192.168.1.113 +services = postgres, mongodb, elasticsearch +nmap_os = linux + +[decky-dbsrv02.postgres] +pg_version = 14.5 + +[decky-dbsrv02.mongodb] +mongo_version = 5.0.9 + +[decky-dbsrv02.elasticsearch] +es_version = 8.4.3 +cluster_name = prod-search + + +# ── Windows workstation / server ────────────────────────────────────────────── +# RDP + SMB + MSSQL — nmap_os=windows gives TTL 128 to complete the fingerprint. + +[decky-winbox] +ip = 192.168.1.114 +services = rdp, smb, mssql +nmap_os = windows + +[decky-winbox.rdp] +os_version = Windows Server 2016 +build = 14393 + +[decky-winbox.smb] +workgroup = CORP +server_name = WINSRV-DC01 +os_version = Windows Server 2016 + +[decky-winbox.mssql] +mssql_version = Microsoft SQL Server 2019 + + +# ── DevOps / Container infra ────────────────────────────────────────────────── + +[decky-devops] +ip = 192.168.1.115 +services = k8s, docker_api +nmap_os = linux + +[decky-devops.k8s] +k8s_version = v1.26.3 + +[decky-devops.docker_api] +docker_version = 24.0.2 + + +# ── Directory / Auth services ───────────────────────────────────────────────── +# Active Directory DC persona — Windows TCP stack matches the LDAP/SMB services. + +[decky-ldapdc] +ip = 192.168.1.116 +services = ldap, ssh +nmap_os = windows + +[decky-ldapdc.ldap] +base_dn = dc=corp,dc=local +domain = corp.local + +[decky-ldapdc.ssh] +ssh_version = OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 +kernel_version = 5.15.0-91-generic +users = root:toor,admin:admin123,svc_backup:backup2024 + + +# ── IoT / Industrial / Network management ───────────────────────────────────── +# TTL 255 is the embedded/network-device giveaway nmap looks for. + +[decky-iot] +ip = 192.168.1.117 +services = mqtt, snmp, conpot +nmap_os = embedded + +[decky-iot.mqtt] +mqtt_version = Mosquitto 2.0.15 + +[decky-iot.snmp] +snmp_community = public +sys_descr = Linux router 5.4.0 #1 SMP x86_64 + + +# ── VoIP / Local network services ──────────────────────────────────────────── + +[decky-voip] +ip = 192.168.1.118 +services = sip, llmnr +nmap_os = linux + +[decky-voip.sip] +sip_server = Asterisk PBX 18.12.0 +sip_domain = pbx.corp.local + + +# ── Legacy admin / remote access ───────────────────────────────────────────── +# Old-school unpatched box — BSD stack for variety. + +[decky-legacy] +ip = 192.168.1.119 +services = telnet, vnc, ssh +nmap_os = bsd + +[decky-legacy.ssh] +ssh_version = OpenSSH_7.4p1 Debian-10+deb9u7 +kernel_version = 4.9.0-19-amd64 +users = root:root,admin:password,pi:raspberry + +[decky-legacy.vnc] +vnc_version = RealVNC 6.7.2 diff --git a/tests/test_ini_loader.py b/tests/test_ini_loader.py index 177adc5..3dd9880 100644 --- a/tests/test_ini_loader.py +++ b/tests/test_ini_loader.py @@ -156,3 +156,62 @@ def test_no_custom_services_gives_empty_list(tmp_path): """) cfg = load_ini(ini_file) assert cfg.custom_services == [] + + +# --------------------------------------------------------------------------- +# nmap_os parsing +# --------------------------------------------------------------------------- + +def test_nmap_os_parsed_from_ini(tmp_path): + ini_file = _write_ini(tmp_path, """ + [decky-win] + ip = 192.168.1.101 + services = rdp, smb + nmap_os = windows + """) + cfg = load_ini(ini_file) + assert cfg.deckies[0].nmap_os == "windows" + + +def test_nmap_os_defaults_to_none_when_absent(tmp_path): + ini_file = _write_ini(tmp_path, """ + [decky-01] + services = ssh + """) + cfg = load_ini(ini_file) + assert cfg.deckies[0].nmap_os is None + + +@pytest.mark.parametrize("os_family", ["linux", "windows", "bsd", "embedded", "cisco"]) +def test_nmap_os_all_families_accepted(tmp_path, os_family): + ini_file = _write_ini(tmp_path, f""" + [decky-01] + services = ssh + nmap_os = {os_family} + """) + cfg = load_ini(ini_file) + assert cfg.deckies[0].nmap_os == os_family + + +def test_nmap_os_propagates_to_amount_expanded_deckies(tmp_path): + ini_file = _write_ini(tmp_path, """ + [corp-printers] + services = snmp + nmap_os = embedded + amount = 3 + """) + cfg = load_ini(ini_file) + assert len(cfg.deckies) == 3 + for d in cfg.deckies: + assert d.nmap_os == "embedded" + + +def test_nmap_os_hyphen_alias_accepted(tmp_path): + """nmap-os= (hyphen) should work as an alias for nmap_os=.""" + ini_file = _write_ini(tmp_path, """ + [decky-01] + services = ssh + nmap-os = bsd + """) + cfg = load_ini(ini_file) + assert cfg.deckies[0].nmap_os == "bsd"