diff --git a/CLAUDE.md b/CLAUDE.md index 26efe4e..ba035d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -413,6 +413,64 @@ ordering, not `Random`; and scan shape/volume (`-T4`, `-Pn` on everything, `--version-intensity 0`, fixed port sets) is untouched — touching 10 ports on every host in a /16 is itself a signature no string substitution addresses. +## Network Override (`interface`/`source_ip`/`router_ip`) + +masscan and nmap both auto-detect which interface to send from and which +next-hop to ARP for by walking the host's own routing table. A host with +more than one default route — a hypervisor's shared-network/NAT adapter +alongside a real LAN gateway, or a split-tunnel VPN client, are the two +observed cases — makes that auto-detection either fail outright (masscan: +`FAIL: could not determine default interface`) or silently pick the wrong +one, so packets go out a path that never reaches the target. The kernel's +own routing (ordinary `socket`-based TCP, which both masscan and nmap also +rely on for everything *except* raw packet crafting) can resolve the same +host correctly, which is why the symptom is counterintuitive: `curl`/`nc` +succeed while an nmap `-Pn` SYN scan against the identical host and port +reports it down or every port filtered. + +Three independent, optional `config.json` keys — `interface`, `source_ip`, +`router_ip` — pin these choices explicitly instead of leaving them guessed. +None has an interactive prompt (same posture as `scanner_profile` and +`honeypot_active_confirm`: hand-edit the file to turn this on); absent or +empty means every masscan/nmap invocation behaves exactly as before. Setting +any one of the three is enough to activate the override — they are not +all-or-nothing — though in practice `interface` and `source_ip` are set +together, since pinning only one rarely disambiguates a two-default-route +host on its own. + +`NetworkOverride` is a plain `(interface, source_ip, router_ip)` namedtuple, +threaded as an explicit `net_override=None` parameter through every function +that builds a masscan or nmap command list — all 3 masscan call sites +(`_discover_external_masscan`, `_discover_internal_masscan`, +`_run_masscan_batch`) and all 9 nmap ones (`_nmap_host_discovery`, +`_nmap_udp_discovery`, `_nmap_port_discovery`, `_build_nmap_cmd`'s three +branches, `_scan_extra_sql_ports`'s two invocations, +`_validate_snmp_any_community`) — the same explicit-parameter-threading +discipline this module uses everywhere else (`max_rate`, `source_port`, +`exclusions_file`), not a module-global, so every call site is independently +testable and a forgotten site is a missing argument, not a silent no-op. +`_masscan_network_args()`/`_nmap_network_args()` are the two translation +points: `interface` → masscan `--interface` / nmap `-e`; `source_ip` → +masscan `--source-ip` / nmap `-S`; `router_ip` → masscan `--router-ip` only, +since nmap resolves its own next-hop once source/interface are fixed and has +no equivalent flag. nmap's `-S` always carries `--disable-arp-ping` +alongside it whenever `source_ip` is set: nmap's own ARP-ping host discovery +bypasses `-S`/`-e` entirely and can contradict the pinned identity against a +target that happens to share the local subnet. The two `_verify_nmap_overlay()` +loopback probes (127.0.0.1, verifying the `scanner_profile` overlay) deliberately +do **not** take this override — pinning a non-loopback interface/source against +a 127.0.0.1 target would break the one thing those probes exist to check. + +`_config_network_override()` validates at config-load time and, like +`_config_scanner_profile()`, **exits rather than warns** on an invalid value: +`interface` must name a real interface on this host (`socket.if_nametoindex()`); +`source_ip`/`router_ip` must each be a valid IPv4 address (this tool's +IPv4-only enforcement applies here too). An operator who set this is already +routing around a host where auto-detection is known unreliable — silently +falling back to it would either reproduce the exact failure this knob exists +to fix, or scan out a path the operator never intended, neither of which +should happen quietly. + ## Architecture ### Host Discovery (Internal) diff --git a/config.json.sample b/config.json.sample index 8ca978e..a0d9ad2 100644 --- a/config.json.sample +++ b/config.json.sample @@ -33,5 +33,9 @@ "exclusions_file" : "exclusions.txt", "__scanner_profile_choices__": "false/absent (default, disabled), \"random\", or an object pinning some of: probe_token, tls_random, rdp_cookie, workstation, native_os, user_agent, smtp_domain — any omitted key is drawn from a built-in pool", "__scanner_profile_note__": "When set, nmap probes and SpooNMAP's bundled NSE scripts no longer send literal strings that identify the scanner (e.g. the RDP probe's \"mstshash=nmap\" cookie, or nmap's own NSE HTTP User-Agent) — see scan_profile.json in the output directory for exactly what was substituted, for engagement deconfliction. Does NOT affect TCP/IP stack fingerprinting, TLS/JA3 fingerprinting, or scan rate/shape; those are unrelated to string substitution.", - "scanner_profile" : false + "scanner_profile" : false, + "__network_override_note__": "Optional, independent knobs for a host where masscan and/or nmap cannot reliably auto-detect which interface, source IP, or next-hop gateway to scan from — e.g. a host with more than one default route (common behind NAT/VPN split-tunnel setups). All three default to absent/empty, meaning masscan/nmap auto-detect as before; setting any one of them is enough to activate the override for that tool. interface maps to masscan --interface and nmap -e. source_ip maps to masscan --source-ip and nmap -S (nmap also gets --disable-arp-ping automatically whenever source_ip is set, since nmap's own ARP-ping host discovery bypasses -S/-e entirely and can contradict the pinned identity against a target on the same local subnet). router_ip maps to masscan --router-ip only — nmap resolves its own next-hop once source/interface are fixed. An invalid interface name or a non-IPv4 address in source_ip/router_ip stops the run rather than silently falling back to auto-detection.", + "interface": "", + "source_ip": "", + "router_ip": "" } diff --git a/spoonmap.py b/spoonmap.py index 7e2ac56..a09fdc8 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -1147,7 +1147,67 @@ def _stream_masscan_progress(proc): return b'\n'.join(stderr_lines) -def _discover_external_masscan(target_file, disc, max_rate, exclusions_file, target_count): +NetworkOverride = collections.namedtuple( + 'NetworkOverride', ('interface', 'source_ip', 'router_ip')) + +# The no-op override: every _masscan_network_args()/_nmap_network_args() call +# site also accepts a bare None for this, so a caller with nothing configured +# never has to construct one. +_NO_NETWORK_OVERRIDE = NetworkOverride(None, None, None) + + +def _masscan_network_args(net_override): + """Extra masscan CLI args for an operator-pinned interface/source/router IP. + + masscan auto-detects its sending interface and next-hop from the host's + own routing table. A host with more than one default route — common + behind NAT/VPN split-tunnel setups (e.g. a hypervisor's shared-network + adapter alongside a real LAN gateway) — makes that auto-detection either + fail outright ("could not determine default interface") or silently pick + the wrong one, sending every packet out a path that never reaches the + target. net_override pins the three masscan knobs that make the choice + explicit instead of guessed. A disabled override (None, the default) + returns no args, so behaviour is byte-for-byte unchanged unless an + operator sets interface/source_ip/router_ip in config.json. + """ + if net_override is None: + return [] + args = [] + if net_override.interface: + args += ['--interface', net_override.interface] + if net_override.source_ip: + args += ['--source-ip', net_override.source_ip] + if net_override.router_ip: + args += ['--router-ip', net_override.router_ip] + return args + + +def _nmap_network_args(net_override): + """Extra nmap CLI args mirroring _masscan_network_args() for the same cause. + + nmap performs the same kind of route/interface auto-detection to craft + and sniff its own raw packets, independent of the kernel's routing table + that ordinary socket I/O (and masscan once pinned) relies on — so the + same ambiguous-default-route host can show a target as fully reachable + over a plain TCP connect while nmap reports it down or filtered. + -S requires --disable-arp-ping alongside it: nmap's own ARP-ping host + discovery bypasses -S/-e entirely and can contradict the pinned identity + against a target on the same local subnet. router_ip has no nmap + equivalent — nmap resolves the next-hop itself once source/interface are + fixed — so it is masscan-only and intentionally unused here. + """ + if net_override is None: + return [] + args = [] + if net_override.interface: + args += ['-e', net_override.interface] + if net_override.source_ip: + args += ['-S', net_override.source_ip, '--disable-arp-ping'] + return args + + +def _discover_external_masscan(target_file, disc, max_rate, exclusions_file, target_count, + net_override=None): """Single masscan TCP SYN sweep for external host discovery. Returns set of live IPs.""" xml_file = os.path.join(disc, 'discovery_masscan_external.xml') print(_COLOR_INFO + 'Host discovery: running masscan (external)...' + _COLOR_RESET) @@ -1158,6 +1218,7 @@ def _discover_external_masscan(target_file, disc, max_rate, exclusions_file, tar '-iL', target_file, '-oX', xml_file, '--wait', _discovery_wait(target_count), + *_masscan_network_args(net_override), ] if exclusions_file: masscan_cmd.extend(['--excludefile', exclusions_file]) @@ -1194,7 +1255,8 @@ def _discover_external_masscan(target_file, disc, max_rate, exclusions_file, tar return ips -def _discover_internal_masscan(target_file, disc, max_rate, exclusions_file, target_count): +def _discover_internal_masscan(target_file, disc, max_rate, exclusions_file, target_count, + net_override=None): """Single masscan TCP SYN sweep for internal host discovery. Returns set of live IPs. Rate capped to INTERNAL_DISCOVERY_MAX_RATE. Port list trimmed to @@ -1216,6 +1278,7 @@ def _discover_internal_masscan(target_file, disc, max_rate, exclusions_file, tar '-iL', target_file, '-oX', xml_file, '--wait', _discovery_wait(target_count), + *_masscan_network_args(net_override), ] if exclusions_file: masscan_cmd.extend(['--excludefile', exclusions_file]) @@ -1252,18 +1315,22 @@ def _discover_internal_masscan(target_file, disc, max_rate, exclusions_file, tar return ips -def _external_host_discovery(target_file, disc, max_rate, exclusions_file, target_count): +def _external_host_discovery(target_file, disc, max_rate, exclusions_file, target_count, + net_override=None): """Masscan TCP SYN sweep + nmap -sn for external host discovery. Returns set of live IPs (union of both passes). """ masscan_ips = _discover_external_masscan( - target_file, disc, max_rate, exclusions_file, target_count) - nmap_ips = _nmap_host_discovery(target_file, disc, '', exclusions_file) + target_file, disc, max_rate, exclusions_file, target_count, + net_override=net_override) + nmap_ips = _nmap_host_discovery(target_file, disc, '', exclusions_file, + net_override=net_override) return masscan_ips | nmap_ips -def _internal_host_discovery(target_file, disc, max_rate, exclusions_file, target_count): +def _internal_host_discovery(target_file, disc, max_rate, exclusions_file, target_count, + net_override=None): """Masscan TCP SYN sweep + concurrent nmap -sn for internal host discovery. For target sets ≤ HOST_DISCOVERY_NMAP_THRESHOLD, nmap -sn runs in a background @@ -1277,7 +1344,8 @@ def _internal_host_discovery(target_file, disc, max_rate, exclusions_file, targe if target_count <= HOST_DISCOVERY_NMAP_THRESHOLD: def _run_nmap(): try: - result = _nmap_host_discovery(target_file, disc, '', exclusions_file) + result = _nmap_host_discovery(target_file, disc, '', exclusions_file, + net_override=net_override) nmap_ips.update(result) except Exception as exc: nmap_errors.append(exc) @@ -1287,7 +1355,8 @@ def _run_nmap(): try: masscan_ips = _discover_internal_masscan( - target_file, disc, max_rate, exclusions_file, target_count) + target_file, disc, max_rate, exclusions_file, target_count, + net_override=net_override) except KeyboardInterrupt: if nmap_thread is not None: nmap_thread.join() @@ -1303,7 +1372,7 @@ def _run_nmap(): def _host_discovery(target_file, output_path, max_rate, exclusions_file, - scan_type='Internal', resume=False): + scan_type='Internal', resume=False, net_override=None): """Run host discovery and write live_hosts_discovery.txt. External scans run a single masscan TCP SYN sweep (curated safe ports) and @@ -1359,10 +1428,12 @@ def _host_discovery(target_file, output_path, max_rate, exclusions_file, if scan_type == 'External': live_ips = _external_host_discovery( - filtered_target, disc, max_rate, discovery_excl, target_count) + filtered_target, disc, max_rate, discovery_excl, target_count, + net_override=net_override) else: # Internal live_ips = _internal_host_discovery( - filtered_target, disc, max_rate, discovery_excl, target_count) + filtered_target, disc, max_rate, discovery_excl, target_count, + net_override=net_override) total = len(live_ips) print(_COLOR_PROGRESS + f'Host discovery total: {total} unique live host(s)' + _COLOR_RESET) @@ -1386,7 +1457,7 @@ def _host_discovery(target_file, output_path, max_rate, exclusions_file, return discovery_file -def _nmap_host_discovery(target_file, disc, source_port, exclusions_file): +def _nmap_host_discovery(target_file, disc, source_port, exclusions_file, net_override=None): """Run nmap -sn (ICMP echo probe only); return set of live IPs.""" output_xml = os.path.join(disc, 'discovery_nmap.xml') cmd = [ @@ -1395,6 +1466,7 @@ def _nmap_host_discovery(target_file, disc, source_port, exclusions_file): '--max-retries', '1', '-PE', # ICMP echo only — TCP SYN probes risk RST from routers for non-existent hosts *(['--source-port', source_port] if source_port else []), + *_nmap_network_args(net_override), '-iL', target_file, '-oX', output_xml, ] @@ -1447,7 +1519,8 @@ def _nmap_host_discovery(target_file, disc, source_port, exclusions_file): return live_ips -def _run_masscan_batch(batch, rate, output_file, target_file, source_port, exclusions_file, wait_secs=2): +def _run_masscan_batch(batch, rate, output_file, target_file, source_port, exclusions_file, + wait_secs=2, net_override=None): """Run masscan for one batch and return {port_key: set_of_ips}.""" # Drop any prior coverage record before overwriting the output it describes. # Without this, a run killed by SIGKILL (or anything else that never reaches @@ -1460,6 +1533,7 @@ def _run_masscan_batch(batch, rate, output_file, target_file, source_port, exclu '--open', '--max-rate', rate, *(['--source-port', source_port] if source_port else []), + *_masscan_network_args(net_override), '-iL', target_file, '-oX', output_file, '--retries', '4', @@ -1669,7 +1743,7 @@ def _delete_previous_results(output_path): def _nmap_udp_discovery(udp_port, target_file, output_path, source_port, - exclusions_file, resume=False): + exclusions_file, resume=False, net_override=None): """Run nmap UDP scan for a single port; return set of candidate IPs. Uses --open (which in nmap captures 'open' and 'open|filtered' states) so @@ -1705,6 +1779,7 @@ def _nmap_udp_discovery(udp_port, target_file, output_path, source_port, '--host-timeout', '30s', '--min-rate', '500', *(['--source-port', source_port] if source_port else []), + *_nmap_network_args(net_override), '-iL', target_file, '-oX', output_file, ] @@ -1799,7 +1874,8 @@ def _stderr_reader(): def _nmap_port_discovery(dest_ports, target_file, source_port, exclusions_file, - scan_type='Full', resume=False, max_rate=None, total_hosts=None): + scan_type='Full', resume=False, max_rate=None, total_hosts=None, + net_override=None): """Run nmap -T4 port discovery in place of masscan for small target sets. Writes live_hosts/port{N}.txt files in the same format as mass_scan() so @@ -1860,6 +1936,7 @@ def _nmap_port_discovery(dest_ports, target_file, source_port, exclusions_file, '--open', '--max-retries', '2', *(['--source-port', source_port] if source_port else []), + *_nmap_network_args(net_override), '-iL', target_file, '-oX', output_file, '--stats-every', '30s', @@ -2328,7 +2405,7 @@ def _confirm_flagged_honeypots(flagged, enabled, dest_ports, disc, connector=Non _report_confirmed_honeypots(confirmed, disc) -def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusions_file, batch_size=1, resume=False, discovery_file=None, target_scan='Internal', honeypot_active_confirm=False): +def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusions_file, batch_size=1, resume=False, discovery_file=None, target_scan='Internal', honeypot_active_confirm=False, net_override=None): status_summary = '\nSummary' if not os.path.exists(f'{_disc(output_path)}/masscan_results'): @@ -2440,7 +2517,7 @@ def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusi # it rather than leaving it to carry a host from a wider one. full_results = _run_masscan_batch(['1-65535'], full_scan_rate, output_file, discovered_target, source_port, exclusions_file, - wait_secs=wait_secs) + wait_secs=wait_secs, net_override=net_override) os.makedirs(f'{disc}/live_hosts', exist_ok=True) for port_key, ips in full_results.items(): # Union with the cached per-port file instead of replacing it, exactly @@ -2494,7 +2571,8 @@ def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusi print(_COLOR_INFO + f'Probe: scanning port {port} at {max_rate} pps...' + _COLOR_RESET) port_fast = _run_masscan_batch([port], max_rate, f'{disc}/masscan_results/probe_fast_{pb_idx}.xml', - discovered_target, source_port, exclusions_file, wait_secs=wait_secs) + discovered_target, source_port, exclusions_file, wait_secs=wait_secs, + net_override=net_override) for k, v in port_fast.items(): fast_results.setdefault(k, set()).update(v) fast_ips = {ip for s in port_fast.values() for ip in s} @@ -2504,7 +2582,8 @@ def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusi print(_COLOR_INFO + f'Probe found 0 hosts at {max_rate} pps — checking {half_rate} pps...' + _COLOR_RESET) port_slow = _run_masscan_batch([port], half_rate, f'{disc}/masscan_results/probe_slow_{pb_idx}.xml', - discovered_target, source_port, exclusions_file, wait_secs=wait_secs) + discovered_target, source_port, exclusions_file, wait_secs=wait_secs, + net_override=net_override) for k, v in port_slow.items(): slow_results.setdefault(k, set()).update(v) slow_ips = {ip for s in port_slow.values() for ip in s} @@ -2522,10 +2601,10 @@ def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusi print(_COLOR_INFO + f'Probe: scanning {probe_label} at {max_rate} pps then {half_rate} pps to check for packet loss...' + _COLOR_RESET) fast_results = _run_masscan_batch(probe_ports, max_rate, f'{disc}/masscan_results/probe_fast.xml', discovered_target, source_port, exclusions_file, - wait_secs=wait_secs) + wait_secs=wait_secs, net_override=net_override) slow_results = _run_masscan_batch(probe_ports, half_rate, f'{disc}/masscan_results/probe_slow.xml', discovered_target, source_port, exclusions_file, - wait_secs=wait_secs) + wait_secs=wait_secs, net_override=net_override) fast_ips = {ip for s in fast_results.values() for ip in s} slow_ips = {ip for s in slow_results.values() for ip in s} new_ips = slow_ips - fast_ips @@ -2709,7 +2788,7 @@ def mass_scan(scan_type, dest_ports, source_port, max_rate, target_file, exclusi print(_COLOR_INFO + f'Scanning ports {batch_label}...' + _COLOR_RESET) batch_results = _run_masscan_batch(batch, effective_rate, output_file, batch_target, source_port, exclusions_file, - wait_secs=batch_wait_secs) + wait_secs=batch_wait_secs, net_override=net_override) if not batch_results: print(_COLOR_INFO + f'\nNo hosts found in batch {batch_idx + 1}/{total_batches} ({batch_label})' + _COLOR_RESET) @@ -2843,7 +2922,7 @@ def _merge_script_args(*pairs): def _build_nmap_cmd(dest_port, input_file, output_file, source_port, script_scan=False, target_scan='Internal', script_only=False, - extra_script_args=None): + extra_script_args=None, net_override=None): """Return the nmap command list for a single port scan. --source-port is omitted for SMB ports when scripts are active: nmap runs all @@ -2863,6 +2942,7 @@ def _build_nmap_cmd(dest_port, input_file, output_file, source_port, ] if source_port and dest_port not in _MULTI_SCRIPT_PORTS: cmd += ['--source-port', source_port] + cmd += _nmap_network_args(net_override) cmd += ['-iL', input_file, '-oX', output_file] scripts = _get_scripts_for_port(dest_port, target_scan) if scripts: @@ -2899,6 +2979,8 @@ def _build_nmap_cmd(dest_port, input_file, output_file, source_port, if source_port and not (script_scan and dest_port in _MULTI_SCRIPT_PORTS): cmd += ['--source-port', source_port] + cmd += _nmap_network_args(net_override) + # Greppable output alongside the XML, one file per port so that each has a # single writer. nmap_scan() runs several workers concurrently, so a shared # -oG target with --append-output would interleave their writes into a @@ -2955,7 +3037,8 @@ def _quarantine_failed_output(output_file): def nmap_worker(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan=False, - target_scan='Internal', start_time=None, extra_script_args=None): + target_scan='Internal', start_time=None, extra_script_args=None, + net_override=None): """Worker thread function to process NMAP scans from queue""" def _report_nmap_failure(pass_label, dest_port, proc, stderr_output, output_file): @@ -3044,6 +3127,7 @@ def _report_nmap_failure(pass_label, dest_port, proc, stderr_output, output_file nmap_cmd = _build_nmap_cmd( dest_port, input_file, output_file, source_port, script_scan=script_scan, target_scan=target_scan, + net_override=net_override, ) # Prior record goes before the scan that replaces its output, so an # externally killed nmap cannot leave a record claiming coverage of @@ -3131,6 +3215,7 @@ def _report_nmap_failure(pass_label, dest_port, proc, stderr_output, output_file dest_port, input_file, nse_output, source_port, script_scan=True, target_scan=target_scan, script_only=True, extra_script_args=extra_script_args, + net_override=net_override, ) _discard_coverage_record(nse_output) nse_process = subprocess.Popen( @@ -3186,7 +3271,8 @@ def _report_nmap_failure(pass_label, dest_port, proc, stderr_output, output_file work_queue.task_done() def nmap_scan(source_port, max_threads=5, ip_to_hostname=None, - script_scan=False, target_scan='Internal', extra_script_args=None): + script_scan=False, target_scan='Internal', extra_script_args=None, + net_override=None): """ Perform NMAP scans using multiple threads for efficiency @@ -3277,7 +3363,7 @@ def nmap_scan(source_port, max_threads=5, ip_to_hostname=None, target=nmap_worker, args=(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, - start_time, extra_script_args) + start_time, extra_script_args, net_override) ) thread.daemon = True thread.start() @@ -3692,7 +3778,7 @@ def _external_exposure_scripts(dest_port): } -def _scan_extra_sql_ports(output_path, source_port): +def _scan_extra_sql_ports(output_path, source_port, net_override=None): """Scan SQL Server named instances discovered on non-standard ports.""" discovered = {} # {host_ip: [port, ...]} @@ -3743,6 +3829,7 @@ def _scan_extra_sql_ports(output_path, source_port): 'nmap', '-n', '-T4', '-sS', '-sV', '--version-intensity', '0', '-Pn', '-p', port, *(['--source-port', source_port] if source_port else []), + *_nmap_network_args(net_override), ip, '-oX', out_file ]) proc.wait() @@ -3767,6 +3854,7 @@ def _scan_extra_sql_ports(output_path, source_port): '--script', f'{_NSE_DIR}/azure-sql-detect.nse', '--script-timeout', '30s', *(['--source-port', source_port] if source_port else []), + *_nmap_network_args(net_override), ip, '-oX', nse_out_file ]) proc.wait() @@ -3776,7 +3864,7 @@ def _scan_extra_sql_ports(output_path, source_port): restore_terminal_state(term_state) -def _validate_snmp_any_community(nmap_dir, scan_type, extra_script_args=None): +def _validate_snmp_any_community(nmap_dir, scan_type, extra_script_args=None, net_override=None): """Return dict {ip: True} for hosts confirmed to accept any SNMP community string.""" import uuid as _uuid _ACCEPTS_ANY_THRESHOLD = 5 @@ -3824,6 +3912,7 @@ def _validate_snmp_any_community(nmap_dir, scan_type, extra_script_args=None): '--script', 'snmp-brute', '--script-args', script_args, '--script-timeout', '30s', + *_nmap_network_args(net_override), ip, ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) @@ -6242,6 +6331,23 @@ def _filter_udp_live_hosts(output_path): 'TLS/JA3 fingerprinting, or scan rate/shape; those are unrelated to string ' 'substitution.'), ], + 'interface': [ + ('__network_override_note__', + 'Optional, independent knobs for a host where masscan and/or nmap cannot ' + 'reliably auto-detect which interface, source IP, or next-hop gateway to ' + 'scan from — e.g. a host with more than one default route (common behind ' + 'NAT/VPN split-tunnel setups). All three default to absent/empty, meaning ' + 'masscan/nmap auto-detect as before; setting any one of them is enough to ' + 'activate the override for that tool. interface maps to masscan --interface ' + 'and nmap -e. source_ip maps to masscan --source-ip and nmap -S (nmap also ' + 'gets --disable-arp-ping automatically whenever source_ip is set, since ' + "nmap's own ARP-ping host discovery bypasses -S/-e entirely and can " + 'contradict the pinned identity against a target on the same local subnet). ' + 'router_ip maps to masscan --router-ip only — nmap resolves its own next-hop ' + 'once source/interface are fixed. An invalid interface name or a non-IPv4 ' + 'address in source_ip/router_ip stops the run rather than silently falling ' + 'back to auto-detection.'), + ], } # Canonical key order for a written config.json, matching config.json.sample. @@ -6250,7 +6356,7 @@ def _filter_udp_live_hosts(output_path): 'script_scan', 'host_discovery', 'resume', 'check_for_updates', 'honeypot_active_confirm', 'target_scan', 'max_rate', 'nmap_threads', 'nmap_threshold', 'target_file', 'output_path', - 'exclusions_file', 'scanner_profile', + 'exclusions_file', 'scanner_profile', 'interface', 'source_ip', 'router_ip', ) @@ -6259,7 +6365,8 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca output_path, exclusions_file, nmap_threads, masscan_batch_size, nmap_threshold, host_discovery, check_for_updates=False, scanner_profile=None, - honeypot_active_confirm=False): + honeypot_active_confirm=False, interface='', + source_ip='', router_ip=''): """Build a config.json-compatible dict from interactively collected options. The result round-trips through main()'s config loader: reloading it @@ -6297,6 +6404,13 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca left out of ``values`` entirely when disabled so a regenerated config.json reads identically to one that never had it — not a `false` cluttering an otherwise-default file. + + ``interface``/``source_ip``/``router_ip`` have no interactive prompt either + (see ``_config_network_override()``) and follow the same rule as a group: + all three are written together whenever any one of them is non-empty, so + the ``interface`` doc note (the only one of the three carrying one, since + it explains all three together) is never silently dropped because the + operator happened to only set ``source_ip``. """ values = { 'banner_scan': bool(banner_scan), @@ -6320,6 +6434,10 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca values['scan_categories'] = scan_categories if scanner_profile: values['scanner_profile'] = scanner_profile + if interface or source_ip or router_ip: + values['interface'] = interface or '' + values['source_ip'] = source_ip or '' + values['router_ip'] = router_ip or '' config = {_CONFIG_GENERATED_KEY: _CONFIG_GENERATED_NOTE} for key in _CONFIG_FIELD_ORDER: @@ -6716,6 +6834,56 @@ def _validate_scanner_token(key, token): sys.exit(1) +def _config_network_override(interface, source_ip, router_ip): + """Build a NetworkOverride from config.json's interface/source_ip/router_ip, or None. + + The three fields are independent — any subset may be set — so a + NetworkOverride is returned whenever at least one is non-empty; None is + returned only when all three are absent/empty, matching this module's + universal "absent means off" rule for optional knobs. router_ip with no + interface is accepted (masscan does not require one), even though in + practice an operator pins interface and source_ip together. + + Unlike _config_bool, an invalid value exits rather than warning and + falling back to disabled, the same posture as _config_scanner_profile(): + an operator who set this is routing around a host where masscan/nmap's + own interface and next-hop auto-detection is already known to be + unreliable (see NetworkOverride above) — falling back silently would + either reproduce that exact failure or send the scan out a path the + operator never intended, neither of which should happen quietly. + """ + interface = (interface or '').strip() or None + source_ip = (source_ip or '').strip() or None + router_ip = (router_ip or '').strip() or None + + if interface is None and source_ip is None and router_ip is None: + return None + + if interface is not None: + try: + socket.if_nametoindex(interface) + except OSError: + print(_COLOR_ERROR + f'ERROR: config.json: interface = {interface!r} is not ' + 'a network interface on this host.' + _COLOR_RESET) + sys.exit(1) + + for key, value in (('source_ip', source_ip), ('router_ip', router_ip)): + if value is None: + continue + try: + ip_obj = ipaddress.ip_address(value) + except ValueError: + print(_COLOR_ERROR + f'ERROR: config.json: {key} = {value!r} is not a valid ' + 'IP address.' + _COLOR_RESET) + sys.exit(1) + if ip_obj.version != 4: + print(_COLOR_ERROR + f'ERROR: config.json: {key} = {value!r} must be IPv4 — ' + 'SpooNMAP scans IPv4 exclusively.' + _COLOR_RESET) + sys.exit(1) + + return NetworkOverride(interface, source_ip, router_ip) + + def _generate_scanner_tokens(overrides): """Return a full 7-key token dict: *overrides* wins, the rest drawn from pools. @@ -7266,6 +7434,13 @@ def _load_config(config_parser, dir_path, resume=False): # only way to enable the active probe. See _maybe_confirm_honeypot(). honeypot_active_confirm = _config_bool( 'honeypot_active_confirm', config_parser.get('honeypot_active_confirm', False), False) + # All three optional and independent; absent/empty means masscan/nmap keep + # auto-detecting as before. See NetworkOverride and _config_network_override() + # for why a host can need this at all. + interface = config_parser.get('interface') or '' + source_ip = config_parser.get('source_ip') or '' + router_ip = config_parser.get('router_ip') or '' + net_override = _config_network_override(interface, source_ip, router_ip) config_generated = bool(config_parser.get(_CONFIG_GENERATED_KEY)) # Resolve relative paths in config relative to the operator directory @@ -7296,6 +7471,10 @@ def _load_config(config_parser, dir_path, resume=False): 'check_for_updates': check_for_updates, 'scanner_profile': scanner_profile, 'honeypot_active_confirm': honeypot_active_confirm, + 'interface': interface, + 'source_ip': source_ip, + 'router_ip': router_ip, + 'net_override': net_override, 'config_generated': config_generated, } @@ -7519,6 +7698,10 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates check_for_updates = False # no interactive prompt; only set via config.json scanner_profile = None # no interactive prompt; only set via config.json honeypot_active_confirm = False # no interactive prompt; only set via config.json + interface = '' # no interactive prompt; only set via config.json + source_ip = '' # no interactive prompt; only set via config.json + router_ip = '' # no interactive prompt; only set via config.json + net_override = None # derived from interface/source_ip/router_ip above # Get options from configuration file if it exists @@ -7558,6 +7741,10 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates check_for_updates = cfg['check_for_updates'] scanner_profile = cfg['scanner_profile'] honeypot_active_confirm = cfg['honeypot_active_confirm'] + interface = cfg['interface'] + source_ip = cfg['source_ip'] + router_ip = cfg['router_ip'] + net_override = cfg['net_override'] _maybe_check_for_updates(check_for_updates) @@ -7869,6 +8056,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates target_scan, max_rate, target_file, output_path, exclusions_file, nmap_threads, masscan_batch_size, nmap_threshold, host_discovery, check_for_updates, scanner_profile, honeypot_active_confirm, + interface, source_ip, router_ip, ) config_json_path = f'{dir_path}/config.json' if _write_interactive_config(config_json_path, interactive_config): @@ -7976,7 +8164,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates if host_discovery: discovery_file = _host_discovery( masscan_target_file, output_path, max_rate, exclusions_file, - scan_type=target_scan, resume=resume, + scan_type=target_scan, resume=resume, net_override=net_override, ) else: discovery_file = None @@ -8005,6 +8193,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates _tcp_dest_ports, _count_file, source_port, exclusions_file, scan_type=scan_type, resume=resume, max_rate=max_rate, total_hosts=effective_host_count, + net_override=net_override, ) # UDP ports are always discovered via nmap regardless of the TCP tool chosen. disc = _disc(output_path) @@ -8014,6 +8203,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates ips = _nmap_udp_discovery( udp_port, udp_target, output_path, source_port, exclusions_file, resume=resume, + net_override=net_override, ) if ips: with open(f'{disc}/live_hosts/port{_port_fname(udp_port)}.txt', 'w') as f: @@ -8036,12 +8226,13 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates resume=resume, discovery_file=discovery_file, target_scan=target_scan, honeypot_active_confirm=honeypot_active_confirm, + net_override=net_override, ) # If service banners requested, send to nmap if banner_scan or script_scan: nmap_scan(source_port, nmap_threads, ip_to_hostname, script_scan, target_scan, - extra_script_args) + extra_script_args, net_override=net_override) udp_confirmed = _filter_udp_live_hosts(output_path) for port_key, count in udp_confirmed.items(): lines = status_summary.split('\n') @@ -8056,12 +8247,12 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates status_summary = '\n'.join(updated) if script_scan and target_scan == 'Internal': - _scan_extra_sql_ports(output_path, source_port) + _scan_extra_sql_ports(output_path, source_port, net_override=net_override) snmp_any_validated = {} if script_scan: snmp_any_validated = _validate_snmp_any_community( - output_path, target_scan, extra_script_args) + output_path, target_scan, extra_script_args, net_override=net_override) ip_to_hostname = _merge_ssl_cert_hostnames(output_path, ip_to_hostname) # Combine all live hosts into one file diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 73f6a37..6ad070e 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -126,7 +126,11 @@ _validate_snmp_any_community, _SMB_COUPLED_PORTS, _build_nmap_overlay, + _config_network_override, _config_scanner_profile, + _masscan_network_args, + _nmap_network_args, + NetworkOverride, _decode_probe_string, _generate_scanner_tokens, _locate_nmap_datadir, @@ -3067,7 +3071,7 @@ def test_full_scan_skips_probe_and_calls_masscan_with_range(self, tmp_path): ['1-65535'], '10000', # capped from 20000 (External cap) str(tmp_path) + '/discovery/masscan_results/portFull.xml', '/fake/targets.txt', '53', '', - wait_secs=2, + wait_secs=2, net_override=None, ) assert 'Hosts Found on Port 80' in result assert 'Hosts Found on Port 443' in result @@ -3081,7 +3085,7 @@ def test_full_scan_rate_capped_internal(self, tmp_path): ['1-65535'], '1000', # capped from 2000 (Internal cap) str(tmp_path) + '/discovery/masscan_results/portFull.xml', '/fake/targets.txt', '88', '', - wait_secs=2, + wait_secs=2, net_override=None, ) def test_full_scan_targets_discovered_hosts_not_full_range(self, tmp_path): @@ -3104,7 +3108,7 @@ def test_full_scan_targets_discovered_hosts_not_full_range(self, tmp_path): ['1-65535'], '1000', str(tmp_path) + '/discovery/masscan_results/portFull.xml', str(discovery_file), '88', '', - wait_secs=0, + wait_secs=0, net_override=None, ) def test_full_scan_falls_back_when_discovery_file_missing(self, tmp_path): @@ -8419,7 +8423,7 @@ def _run(): def _fake_worker_drain(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, start_time, - extra_script_args=None): + extra_script_args=None, net_override=None): """Stand-in for nmap_worker() used by TestNmapScan — drains real queue items instantly instead of spawning subprocesses, so nmap_scan()'s own orchestration logic (queuing, resume-skip, join/poison-pill, thread @@ -8599,7 +8603,7 @@ def test_keyboard_interrupt_sets_event_and_reraises(self, tmp_path): def fake_worker(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, start_time, - extra_script_args=None): + extra_script_args=None, net_override=None): captured_event['event'] = interrupt_event # Never drains the queue — join() below will block until we # interrupt it via the patched Queue.join raising KeyboardInterrupt. @@ -8627,7 +8631,7 @@ def test_hostnames_txt_file_filtered_out(self, tmp_path): def fake_worker_capture(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, start_time, - extra_script_args=None): + extra_script_args=None, net_override=None): while True: item = work_queue.get() if item is None: @@ -8656,7 +8660,7 @@ def test_udp_port_with_hostnames_filtered(self, tmp_path): def fake_worker_capture(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, start_time, - extra_script_args=None): + extra_script_args=None, net_override=None): while True: item = work_queue.get() if item is None: @@ -8685,7 +8689,7 @@ def test_stray_ds_store_filtered_out(self, tmp_path): def fake_worker_capture(work_queue, completed_count, total_count, source_port, lock, interrupt_event, ip_to_hostname, script_scan, target_scan, start_time, - extra_script_args=None): + extra_script_args=None, net_override=None): while True: item = work_queue.get() if item is None: @@ -10876,7 +10880,7 @@ def _make_batch_side_effect(port_map): calls = [] def side_effect(batch, rate, output_file, target_file, source_port, - exclusions_file, wait_secs=2): + exclusions_file, wait_secs=2, net_override=None): idx = len(calls) calls.append(batch) return port_map.get(idx, {}) @@ -10953,7 +10957,7 @@ def test_probe_found_445_always_gets_solo_scan(self, tmp_path): call_log = [] def side_effect(batch, rate, output_file, target_file, source_port, - exclusions_file, wait_secs=2): + exclusions_file, wait_secs=2, net_override=None): call_log.append(list(batch)) # First call is the fast probe — simulate finding 445 return {'445': {'10.0.0.1'}} if len(call_log) == 1 else {} @@ -15050,6 +15054,196 @@ def test_lua_string_tokens_allow_ordinary_text(self, key): _validate_scanner_token(key, 'DESKTOP-4KJ2L1P') # must not raise +class TestMasscanNetworkArgs: + def test_none_override_returns_no_args(self): + assert _masscan_network_args(None) == [] + + def test_all_disabled_returns_no_args(self): + assert _masscan_network_args(NetworkOverride(None, None, None)) == [] + + def test_interface_only(self): + args = _masscan_network_args(NetworkOverride('eth0', None, None)) + assert args == ['--interface', 'eth0'] + + def test_source_ip_only(self): + args = _masscan_network_args(NetworkOverride(None, '10.0.0.5', None)) + assert args == ['--source-ip', '10.0.0.5'] + + def test_router_ip_only(self): + args = _masscan_network_args(NetworkOverride(None, None, '10.0.0.1')) + assert args == ['--router-ip', '10.0.0.1'] + + def test_all_three_set(self): + args = _masscan_network_args(NetworkOverride('eth0', '10.0.0.5', '10.0.0.1')) + assert args == ['--interface', 'eth0', '--source-ip', '10.0.0.5', + '--router-ip', '10.0.0.1'] + + +class TestNmapNetworkArgs: + def test_none_override_returns_no_args(self): + assert _nmap_network_args(None) == [] + + def test_all_disabled_returns_no_args(self): + assert _nmap_network_args(NetworkOverride(None, None, None)) == [] + + def test_interface_only(self): + args = _nmap_network_args(NetworkOverride('eth0', None, None)) + assert args == ['-e', 'eth0'] + + def test_source_ip_adds_disable_arp_ping(self): + args = _nmap_network_args(NetworkOverride(None, '10.0.0.5', None)) + assert args == ['-S', '10.0.0.5', '--disable-arp-ping'] + + def test_router_ip_has_no_nmap_equivalent(self): + # router_ip is masscan-only; nmap resolves its own next-hop. + args = _nmap_network_args(NetworkOverride(None, None, '10.0.0.1')) + assert args == [] + + def test_all_three_set_router_ip_ignored(self): + args = _nmap_network_args(NetworkOverride('eth0', '10.0.0.5', '10.0.0.1')) + assert args == ['-e', 'eth0', '-S', '10.0.0.5', '--disable-arp-ping'] + + +class TestConfigNetworkOverride: + def test_all_absent_returns_none(self): + assert _config_network_override('', '', '') is None + + def test_all_none_returns_none(self): + assert _config_network_override(None, None, None) is None + + def test_interface_only_valid(self): + # A real interface name varies by OS (lo on Linux, lo0 on macOS), so + # the existence check itself is mocked rather than relying on one. + with patch('spoonmap.socket.if_nametoindex', return_value=1): + result = _config_network_override('eth0', '', '') + assert result == NetworkOverride('eth0', None, None) + + def test_invalid_interface_exits(self, capsys): + with pytest.raises(SystemExit) as exc: + _config_network_override('not-a-real-interface-xyz', '', '') + assert exc.value.code == 1 + assert 'interface' in capsys.readouterr().out + + def test_valid_source_ip(self): + result = _config_network_override('', '10.0.0.5', '') + assert result == NetworkOverride(None, '10.0.0.5', None) + + def test_invalid_source_ip_exits(self, capsys): + with pytest.raises(SystemExit): + _config_network_override('', 'not-an-ip', '') + assert 'source_ip' in capsys.readouterr().out + + def test_ipv6_source_ip_rejected(self, capsys): + # IPv4-only enforcement, same as the rest of this module. + with pytest.raises(SystemExit): + _config_network_override('', '::1', '') + assert 'IPv4' in capsys.readouterr().out + + def test_valid_router_ip(self): + result = _config_network_override('', '', '10.0.0.1') + assert result == NetworkOverride(None, None, '10.0.0.1') + + def test_invalid_router_ip_exits(self, capsys): + with pytest.raises(SystemExit): + _config_network_override('', '', 'not-an-ip') + assert 'router_ip' in capsys.readouterr().out + + def test_router_ip_alone_without_interface_is_accepted(self): + # masscan does not require --interface alongside --router-ip. + result = _config_network_override('', '', '10.0.0.1') + assert result.interface is None + assert result.router_ip == '10.0.0.1' + + def test_all_three_set(self): + with patch('spoonmap.socket.if_nametoindex', return_value=1): + result = _config_network_override('eth0', '10.0.0.5', '10.0.0.1') + assert result == NetworkOverride('eth0', '10.0.0.5', '10.0.0.1') + + def test_whitespace_only_treated_as_absent(self): + assert _config_network_override(' ', ' ', ' ') is None + + +class TestBuildNmapCmdNetworkOverride: + """_build_nmap_cmd() splices _nmap_network_args() into every branch.""" + + def test_banner_pass_tcp(self): + cmd = _build_nmap_cmd('445', '/in.txt', '/out.xml', '', + target_scan='Internal', + net_override=NetworkOverride('eth0', '10.0.0.5', None)) + assert '-e' in cmd and cmd[cmd.index('-e') + 1] == 'eth0' + assert '-S' in cmd and cmd[cmd.index('-S') + 1] == '10.0.0.5' + assert '--disable-arp-ping' in cmd + + def test_banner_pass_udp(self): + cmd = _build_nmap_cmd('U:161', '/in.txt', '/out.xml', '', + target_scan='Internal', + net_override=NetworkOverride('eth0', None, None)) + assert '-e' in cmd and cmd[cmd.index('-e') + 1] == 'eth0' + + def test_script_only_pass(self): + cmd = _build_nmap_cmd('22', '/in.txt', '/out.xml', '', + script_only=True, target_scan='Internal', + net_override=NetworkOverride('eth0', None, None)) + assert '-e' in cmd and cmd[cmd.index('-e') + 1] == 'eth0' + + def test_no_override_means_no_extra_args(self): + cmd = _build_nmap_cmd('22', '/in.txt', '/out.xml', '', target_scan='Internal') + assert '-e' not in cmd + assert '-S' not in cmd + assert '--disable-arp-ping' not in cmd + + +class TestRunMasscanBatchNetworkOverride: + def _make_mock_proc(self): + mock_proc = MagicMock() + mock_proc.wait.return_value = 0 + mock_proc.returncode = 0 + mock_proc.pid = 12345 + mock_proc.stderr = io.BytesIO(b'') + return mock_proc + + def test_network_override_args_in_command(self, tmp_path): + output_xml = tmp_path / 'out.xml' + captured_cmds = [] + + def fake_popen(cmd, **kwargs): + captured_cmds.append(cmd) + output_xml.write_text('') + return self._make_mock_proc() + + with patch('spoonmap.subprocess.Popen', side_effect=fake_popen), \ + patch('spoonmap.save_terminal_state', return_value=None), \ + patch('spoonmap.restore_terminal_state'): + _run_masscan_batch(['445'], '1000', str(output_xml), + '/fake/targets.txt', None, None, + net_override=NetworkOverride('eth0', '10.0.0.5', '10.0.0.1')) + + cmd = captured_cmds[0] + assert cmd[cmd.index('--interface') + 1] == 'eth0' + assert cmd[cmd.index('--source-ip') + 1] == '10.0.0.5' + assert cmd[cmd.index('--router-ip') + 1] == '10.0.0.1' + + def test_no_override_means_no_extra_args(self, tmp_path): + output_xml = tmp_path / 'out.xml' + captured_cmds = [] + + def fake_popen(cmd, **kwargs): + captured_cmds.append(cmd) + output_xml.write_text('') + return self._make_mock_proc() + + with patch('spoonmap.subprocess.Popen', side_effect=fake_popen), \ + patch('spoonmap.save_terminal_state', return_value=None), \ + patch('spoonmap.restore_terminal_state'): + _run_masscan_batch(['445'], '1000', str(output_xml), + '/fake/targets.txt', None, None) + + cmd = captured_cmds[0] + assert '--interface' not in cmd + assert '--source-ip' not in cmd + assert '--router-ip' not in cmd + + class TestGenerateScannerTokens: def test_empty_overrides_fills_all_seven_keys(self): tokens = _generate_scanner_tokens({}) @@ -15662,3 +15856,71 @@ def test_empty_dict_omits_key(self): # this only guards against a future caller passing {} by mistake. cfg = _build_interactive_config(*self._base_args(), scanner_profile={}) assert 'scanner_profile' not in cfg + + +class TestLoadConfigNetworkOverride: + def test_absent_keys_disabled(self): + cfg = _load_config(_config_dict(), '/t') + assert cfg['net_override'] is None + assert cfg['interface'] == '' + assert cfg['source_ip'] == '' + assert cfg['router_ip'] == '' + + def test_interface_only(self): + # A real interface name varies by OS (lo on Linux, lo0 on macOS), so + # the existence check itself is mocked rather than relying on one. + with patch('spoonmap.socket.if_nametoindex', return_value=1): + cfg = _load_config(_config_dict(interface='eth0'), '/t') + assert cfg['net_override'] == NetworkOverride('eth0', None, None) + assert cfg['interface'] == 'eth0' + + def test_source_ip_only(self): + cfg = _load_config(_config_dict(source_ip='10.0.0.5'), '/t') + assert cfg['net_override'] == NetworkOverride(None, '10.0.0.5', None) + + def test_all_three_set(self): + with patch('spoonmap.socket.if_nametoindex', return_value=1): + cfg = _load_config( + _config_dict(interface='eth0', source_ip='10.0.0.5', router_ip='10.0.0.1'), '/t') + assert cfg['net_override'] == NetworkOverride('eth0', '10.0.0.5', '10.0.0.1') + + def test_invalid_interface_exits(self): + with pytest.raises(SystemExit): + _load_config(_config_dict(interface='not-a-real-interface-xyz'), '/t') + + def test_invalid_source_ip_exits(self): + with pytest.raises(SystemExit): + _load_config(_config_dict(source_ip='not-an-ip'), '/t') + + +class TestBuildInteractiveConfigNetworkOverride: + def _base_args(self): + return (['Web'], [], 'Web', True, False, 'Internal', '2000', + 'r', 'o', None, 5, 5, 5_000_000, True) + + def test_all_empty_omits_keys(self): + cfg = _build_interactive_config(*self._base_args()) + assert 'interface' not in cfg + assert 'source_ip' not in cfg + assert 'router_ip' not in cfg + + def test_interface_only_writes_all_three_keys(self): + # The doc note is attached only to 'interface' in _CONFIG_DOCS, so all + # three must be written together whenever any one is set, or the note + # silently disappears when only source_ip/router_ip is set. + cfg = _build_interactive_config(*self._base_args(), interface='eth0') + assert cfg['interface'] == 'eth0' + assert cfg['source_ip'] == '' + assert cfg['router_ip'] == '' + + def test_source_ip_only_still_writes_interface_key(self): + cfg = _build_interactive_config(*self._base_args(), source_ip='10.0.0.5') + assert cfg['interface'] == '' + assert cfg['source_ip'] == '10.0.0.5' + + def test_all_three_set(self): + cfg = _build_interactive_config( + *self._base_args(), interface='eth0', source_ip='10.0.0.5', router_ip='10.0.0.1') + assert cfg['interface'] == 'eth0' + assert cfg['source_ip'] == '10.0.0.5' + assert cfg['router_ip'] == '10.0.0.1'