diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py index e6c9700..1a2b4a8 100644 --- a/backend/app/services/fingerprint.py +++ b/backend/app/services/fingerprint.py @@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]: return results -# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated) +# Known OUI prefixes — lowercase, colon-separated, first 3 octets _MAC_OUI_TYPES: dict[str, str] = { - "52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs) - "bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+) + # Hypervisors / VMs + "52:54:00": "vm", # QEMU/KVM (Proxmox VMs) + "bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+) "00:50:56": "vm", # VMware "00:0c:29": "vm", # VMware Workstation / Fusion "08:00:27": "vm", # VirtualBox "00:15:5d": "vm", # Hyper-V + # Shelly + "34:94:54": "iot", + "84:f3:eb": "iot", + "ec:fa:bc": "iot", + "30:c6:f7": "iot", + # Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT) + "a0:20:a6": "iot", + "24:62:ab": "iot", + "30:ae:a4": "iot", + "cc:50:e3": "iot", + "ac:67:b2": "iot", + "b4:e6:2d": "iot", + "3c:71:bf": "iot", + "8c:aa:b5": "iot", + # Sonoff / ITEAD + "dc:4f:22": "iot", + "e8:db:84": "iot", + # Tapo / TP-Link smart home + "b0:a7:b9": "iot", + "50:c7:bf": "iot", + "1c:3b:f3": "iot", + "10:27:f5": "iot", + # Philips Hue + "00:17:88": "iot", + "ec:b5:fa": "iot", + # IKEA Tradfri + "34:13:e8": "iot", + "00:21:2e": "iot", + # Tuya / Smart Life (widely used chip in many brands) + "d8:f1:5b": "iot", + "68:57:2d": "iot", } @@ -101,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = { 37777: "camera", # Dahua 34567: "camera", # Amcrest 2020: "camera", # Tapo - # Smart-home / MQTT → iot + # Smart-home / MQTT / CoAP → iot 1883: "iot", 8883: "iot", - 6052: "iot", # ESPHome + 6052: "iot", # ESPHome dashboard + 4915: "iot", # Shelly CoIoT + 5683: "iot", # CoAP (Shelly Gen1, many IoT devices) + 5684: "iot", # CoAP DTLS # AP / wireless 8880: "ap", # UniFi HTTP 8443: "ap", # UniFi HTTPS @@ -115,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = { def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str: - """Suggest a node type based on matched signatures and MAC OUI.""" - priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"] + """Suggest a node type based on matched signatures, port hints, and MAC OUI.""" + # IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override + mac_type = suggest_type_from_mac(mac) + if mac_type == "iot": + return "iot" + + priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"] found: set[str] = set() for p in open_ports: port = p["port"] @@ -126,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) found.add(sig["suggested_node_type"]) if port in _PORT_TYPE_HINTS: found.add(_PORT_TYPE_HINTS[port]) - # MAC OUI is a lower-priority hint — only used if ports give no better answer - mac_type = suggest_type_from_mac(mac) + if mac_type: found.add(mac_type) + for t in priority: if t in found: return t diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 5899503..3ac8da5 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,10 +1,11 @@ -"""Network scanner: ARP sweep + nmap service detection.""" +"""Network scanner: ARP sweep + nmap service detection + mDNS discovery.""" import asyncio import logging import socket from datetime import datetime, timezone from typing import Any +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Node, PendingDevice, ScanRun @@ -15,15 +16,26 @@ logger = logging.getLogger(__name__) # Run IDs that have been requested to cancel _cancelled_runs: set[str] = set() +# Port list for service detection (Phase 2) +_EXTRA_PORTS = ( + "80,443,22,21,23,25,53,110,143,161,162,179,389,445,548," + "554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306," + "3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984," + "6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080," + "8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443," + "8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090," + "9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051," + "16686,34567,37777,51413,64738" +) -def request_cancel(run_id: str) -> None: - """Signal a running scan to stop early.""" - _cancelled_runs.add(run_id) - - -def _is_cancelled(run_id: str) -> bool: - return run_id in _cancelled_runs - +_MDNS_SERVICE_TYPES = [ + "_http._tcp.local.", + "_shelly._tcp.local.", + "_esphomelib._tcp.local.", + "_hap._tcp.local.", # HomeKit Accessory Protocol + "_mqtt._tcp.local.", + "_device-info._tcp.local.", +] try: import nmap @@ -32,50 +44,22 @@ except ImportError: _NMAP_AVAILABLE = False logger.warning("python-nmap not available — scanner will run in mock mode") +try: + from zeroconf import ServiceStateChange + from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + _ZEROCONF_AVAILABLE = True +except ImportError: + _ZEROCONF_AVAILABLE = False + logger.warning("zeroconf not available — mDNS discovery disabled") -def _nmap_scan(target: str) -> list[dict[str, Any]]: - """Run nmap -sV --open on target, return list of host dicts.""" - if not _NMAP_AVAILABLE: - return _mock_scan(target) - nm = nmap.PortScanner() - try: - # Home lab port range: standard top-1000 + common self-hosted service ports - extra_ports = ( - "80,443,22,21,23,25,53,110,143,161,162,179,389,445,548," - "554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306," - "3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443," - "6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096," - "8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883," - "8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300," - "9411,9443,9696,10051,16686,34567,37777,51413,64738" - ) - nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}") - except Exception as exc: - logger.error("nmap scan failed: %s", exc) - raise RuntimeError(str(exc)) from exc +def request_cancel(run_id: str) -> None: + """Signal a running scan to stop early.""" + _cancelled_runs.add(run_id) - hosts = [] - for host in nm.all_hosts(): - if nm[host].state() != "up": - continue - open_ports = [] - for proto in nm[host].all_protocols(): - for port, info in nm[host][proto].items(): - if info["state"] == "open": - open_ports.append({ - "port": port, - "protocol": proto, - "banner": info.get("product", "") + " " + info.get("version", ""), - }) - hosts.append({ - "ip": host, - "hostname": _resolve_hostname(host), - "mac": nm[host].get("addresses", {}).get("mac"), - "os": _extract_os(nm, host), - "open_ports": open_ports, - }) - return hosts + +def _is_cancelled(run_id: str) -> bool: + return run_id in _cancelled_runs def _resolve_hostname(ip: str) -> str | None: @@ -95,6 +79,142 @@ def _extract_os(nm: object, host: str) -> str | None: return None +def _nmap_arp_sweep(target: str) -> dict[str, dict[str, Any]]: + """ + Phase 1: ARP ping sweep — finds ALL alive hosts regardless of open ports. + Returns {ip: host_dict} for every host that responds. + """ + nm = nmap.PortScanner() + nm.scan(hosts=target, arguments="-sn -PR -PA80,443 --host-timeout 10s") + alive: dict[str, dict[str, Any]] = {} + for host in nm.all_hosts(): + if nm[host].state() == "up": + alive[host] = { + "ip": host, + "hostname": _resolve_hostname(host), + "mac": nm[host].get("addresses", {}).get("mac"), + "os": None, + "open_ports": [], + } + return alive + + +def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + """ + Phase 2: Service detection on the alive host set from Phase 1. + Mutates alive in-place with open_ports/os; returns all hosts including + those with zero open ports (IoT devices often have none). + """ + if not alive: + return [] + nm = nmap.PortScanner() + try: + nm.scan( + hosts=" ".join(alive.keys()), + arguments=f"-sV --open -T4 --host-timeout 30s -p {_EXTRA_PORTS}", + ) + except Exception as exc: + logger.warning("Port scan failed, returning ARP-only results: %s", exc) + return list(alive.values()) + + for host in nm.all_hosts(): + if host not in alive: + continue + open_ports = [] + for proto in nm[host].all_protocols(): + for port, info in nm[host][proto].items(): + if info["state"] == "open": + open_ports.append({ + "port": port, + "protocol": proto, + "banner": ( + info.get("product", "") + " " + info.get("version", "") + ).strip(), + }) + alive[host]["open_ports"] = open_ports + if not alive[host]["mac"]: + alive[host]["mac"] = nm[host].get("addresses", {}).get("mac") + alive[host]["os"] = _extract_os(nm, host) + + return list(alive.values()) + + +def _nmap_scan(target: str) -> list[dict[str, Any]]: + """ + Full two-phase scan for a CIDR range. + Phase 1: ARP sweep to find alive hosts (catches IoT with no open ports). + Phase 2: Service detection on alive hosts only. + """ + if not _NMAP_AVAILABLE: + return _mock_scan(target) + try: + alive = _nmap_arp_sweep(target) + except Exception as exc: + logger.error("nmap ARP sweep failed: %s", exc) + raise RuntimeError(str(exc)) from exc + return _nmap_port_scan(alive) + + +async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]: + """ + Passive mDNS/Bonjour sweep. + Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc. + Runs for `timeout` seconds then returns what it found. + """ + if not _ZEROCONF_AVAILABLE: + return [] + + import ipaddress + + found_services: list[tuple[str, str]] = [] + + def _on_change( + zeroconf: Any, + service_type: str, + name: str, + state_change: Any, + ) -> None: + if state_change == ServiceStateChange.Added: + found_services.append((service_type, name)) + + discovered: dict[str, dict[str, Any]] = {} + + try: + async with AsyncZeroconf() as azc: + browser = AsyncServiceBrowser( + azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change] + ) + await asyncio.sleep(timeout) + await browser.async_cancel() + + for service_type, name in found_services: + try: + info = AsyncServiceInfo(service_type, name) + await info.async_request(azc.zeroconf, 3000) + if not info.addresses: + continue + ip = str(ipaddress.IPv4Address(info.addresses[0])) + if ip in discovered: + continue + discovered[ip] = { + "ip": ip, + "hostname": info.server, + "mac": None, + "os": None, + "open_ports": ( + [{"port": info.port, "protocol": "tcp", "banner": ""}] + if info.port else [] + ), + } + except Exception as exc: + logger.debug("mDNS resolution failed for %s: %s", name, exc) + except Exception as exc: + logger.warning("mDNS discovery error: %s", exc) + + logger.info("mDNS discovery found %d device(s)", len(discovered)) + return list(discovered.values()) + + def _mock_scan(target: str) -> list[dict[str, Any]]: """Return fake results for dev/test environments without nmap.""" return [ @@ -113,17 +233,13 @@ def _mock_scan(target: str) -> list[dict[str, Any]]: async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: """Execute scan for given CIDR ranges and populate pending_devices.""" - # Avoid circular import - from sqlalchemy import select - from app.api.routes.status import broadcast_scan_update devices_found = 0 try: # Clean up stale pending devices whose IPs are already in the canvas - # (covers devices approved between scans, or pre-existing canvas nodes) canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None))) - canvas_ips = {row[0] for row in canvas_ips_result.fetchall()} + canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()} if canvas_ips: stale_result = await db.execute( select(PendingDevice).where( @@ -135,78 +251,97 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: await db.delete(stale) await db.commit() + # Start mDNS discovery in the background while nmap scans run + mdns_task: asyncio.Task[list[dict[str, Any]]] = asyncio.create_task( + _mdns_discover() + ) + + # Track IPs found by nmap so mDNS doesn't duplicate them + nmap_ips: set[str] = set() + + async def _process_host(host: dict[str, Any]) -> None: + nonlocal devices_found + ip = host["ip"] + + # Skip canvas nodes and user-hidden devices + canvas_result = await db.execute(select(Node).where(Node.ip == ip)) + if canvas_result.scalar_one_or_none() is not None: + logger.debug("Skipping %s — already in canvas", ip) + return + hidden_result = await db.execute( + select(PendingDevice).where( + PendingDevice.ip == ip, + PendingDevice.status == "hidden", + ) + ) + if hidden_result.scalar_one_or_none() is not None: + logger.debug("Skipping %s — hidden by user", ip) + return + + services = fingerprint_ports(host["open_ports"]) + suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) + + existing_result = await db.execute( + select(PendingDevice).where( + PendingDevice.ip == ip, + PendingDevice.status == "pending", + ) + ) + existing = existing_result.scalar_one_or_none() + if existing: + existing.mac = host.get("mac") or existing.mac + existing.hostname = host.get("hostname") or existing.hostname + existing.os = host.get("os") or existing.os + existing.services = services + existing.suggested_type = suggested_type + else: + db.add(PendingDevice( + ip=ip, + mac=host.get("mac"), + hostname=host.get("hostname"), + os=host.get("os"), + services=services, + suggested_type=suggested_type, + status="pending", + )) + devices_found += 1 + + await db.commit() + + run = await db.get(ScanRun, run_id) + if run: + run.devices_found = devices_found + await db.commit() + + await broadcast_scan_update(run_id=run_id, devices_found=devices_found) + + # nmap scan per CIDR — results stream in progressively for cidr in ranges: if _is_cancelled(run_id): break - - # Run nmap in a thread pool — does not block the event loop hosts = await asyncio.to_thread(_nmap_scan, cidr) - for host in hosts: if _is_cancelled(run_id): break - ip = host["ip"] + nmap_ips.add(host["ip"]) + await _process_host(host) - # Skip if device is already in the canvas (approved node) - canvas_result = await db.execute( - select(Node).where(Node.ip == ip) - ) - if canvas_result.scalar_one_or_none() is not None: - logger.debug("Skipping %s — already in canvas", ip) - continue + # Collect mDNS results; add devices not already found by nmap + if not _is_cancelled(run_id): + try: + mdns_hosts = await asyncio.wait_for(mdns_task, timeout=1.0) + except asyncio.TimeoutError: + mdns_task.cancel() + mdns_hosts = [] - # Skip if device was explicitly hidden by the user - hidden_result = await db.execute( - select(PendingDevice).where( - PendingDevice.ip == ip, - PendingDevice.status == "hidden", - ) - ) - if hidden_result.scalar_one_or_none() is not None: - logger.debug("Skipping %s — hidden by user", ip) - continue - - services = fingerprint_ports(host["open_ports"]) - suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) - - # Update existing pending device or create a new one - existing_result = await db.execute( - select(PendingDevice).where( - PendingDevice.ip == ip, - PendingDevice.status == "pending", - ) - ) - existing = existing_result.scalar_one_or_none() - if existing: - existing.mac = host.get("mac") or existing.mac - existing.hostname = host.get("hostname") or existing.hostname - existing.os = host.get("os") or existing.os - existing.services = services - existing.suggested_type = suggested_type - else: - device = PendingDevice( - ip=ip, - mac=host.get("mac"), - hostname=host.get("hostname"), - os=host.get("os"), - services=services, - suggested_type=suggested_type, - status="pending", - ) - db.add(device) - devices_found += 1 - - # Commit immediately so the device is visible right away - await db.commit() - - # Update running count on the scan run record - run = await db.get(ScanRun, run_id) - if run: - run.devices_found = devices_found - await db.commit() - - # Push WS event so the frontend refreshes pending panel - await broadcast_scan_update(run_id=run_id, devices_found=devices_found) + for host in mdns_hosts: + if _is_cancelled(run_id): + break + if host["ip"] in nmap_ips: + continue # already processed with richer nmap data + await _process_host(host) + else: + mdns_task.cancel() # Mark scan as done or cancelled run = await db.get(ScanRun, run_id) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3eaef79..792a5bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,7 @@ pyyaml==6.0.2 types-PyYAML==6.0.12.20240917 websockets==13.1 httpx==0.27.2 +zeroconf==0.131.0 # Dev ruff==0.6.9 diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py index 0b060f0..713b9bf 100644 --- a/backend/tests/test_fingerprint.py +++ b/backend/tests/test_fingerprint.py @@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature(): ]): result = suggest_node_type([{"port": 554, "protocol": "tcp"}]) assert result == "camera" + + +# ── IoT detection ───────────────────────────────────────────────────────────── + +def test_suggest_node_type_iot_from_mqtt_port(): + result = suggest_node_type([{"port": 1883, "protocol": "tcp"}]) + assert result == "iot" + + +def test_suggest_node_type_iot_from_coap_port(): + result = suggest_node_type([{"port": 5683, "protocol": "tcp"}]) + assert result == "iot" + + +def test_suggest_node_type_iot_from_esphome_port(): + result = suggest_node_type([{"port": 6052, "protocol": "tcp"}]) + assert result == "iot" + + +def test_suggest_node_type_shelly_mac_overrides_http_port(): + # Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT + result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc") + assert result == "iot" + + +def test_suggest_node_type_espressif_mac_returns_iot(): + result = suggest_node_type([], mac="a0:20:a6:11:22:33") + assert result == "iot" + + +def test_suggest_node_type_tuya_mac_returns_iot(): + result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc") + assert result == "iot" + + +def test_suggest_node_type_iot_wins_over_server_when_mqtt_present(): + # MQTT port + HTTP port → iot wins (iot is higher priority than server now) + result = suggest_node_type([ + {"port": 80, "protocol": "tcp"}, + {"port": 1883, "protocol": "tcp"}, + ]) + assert result == "iot" diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py new file mode 100644 index 0000000..d368832 --- /dev/null +++ b/backend/tests/test_scanner.py @@ -0,0 +1,306 @@ +"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration.""" +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.db.database import Base +from app.db.models import PendingDevice, ScanRun + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_run_id() -> str: + return str(uuid.uuid4()) + + +@pytest.fixture +async def mem_db(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield factory + await engine.dispose() + + +def _make_scan_run(run_id: str) -> ScanRun: + return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]) + + +# --------------------------------------------------------------------------- +# _nmap_arp_sweep +# --------------------------------------------------------------------------- + +def test_nmap_arp_sweep_returns_alive_hosts(): + from app.services.scanner import _nmap_arp_sweep + + mock_nm = MagicMock() + mock_nm.all_hosts.return_value = ["192.168.1.1", "192.168.1.2"] + mock_nm.__getitem__ = lambda self, host: MagicMock( + state=lambda: "up", + get=lambda key, default=None: {"mac": "aa:bb:cc:dd:ee:ff"} if key == "addresses" else default, + ) + + with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ + patch("app.services.scanner._resolve_hostname", return_value=None): + result = _nmap_arp_sweep("192.168.1.0/24") + + assert set(result.keys()) == {"192.168.1.1", "192.168.1.2"} + for host in result.values(): + assert host["open_ports"] == [] # empty until phase 2 + + +def test_nmap_arp_sweep_skips_down_hosts(): + from app.services.scanner import _nmap_arp_sweep + + states = {"192.168.1.1": "up", "192.168.1.2": "down"} + + mock_nm = MagicMock() + mock_nm.all_hosts.return_value = list(states.keys()) + + def getitem(host): + m = MagicMock() + m.state.return_value = states[host] + m.get.return_value = {} + return m + + mock_nm.__getitem__ = lambda self, host: getitem(host) + + with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ + patch("app.services.scanner._resolve_hostname", return_value=None): + result = _nmap_arp_sweep("192.168.1.0/24") + + assert "192.168.1.1" in result + assert "192.168.1.2" not in result + + +# --------------------------------------------------------------------------- +# _nmap_port_scan +# --------------------------------------------------------------------------- + +def test_nmap_port_scan_merges_ports(): + from app.services.scanner import _nmap_port_scan + + alive = { + "192.168.1.10": {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}, + } + + mock_nm = MagicMock() + mock_nm.all_hosts.return_value = ["192.168.1.10"] + mock_nm.__getitem__ = lambda self, host: MagicMock( + all_protocols=lambda: ["tcp"], + **{"__getitem__": lambda self2, proto: { + 80: {"state": "open", "product": "nginx", "version": "1.24"}, + }}, + get=lambda key, default=None: default, + ) + + with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ + patch("app.services.scanner._extract_os", return_value=None): + result = _nmap_port_scan(alive) + + assert len(result) == 1 + assert result[0]["open_ports"][0]["port"] == 80 + + +def test_nmap_port_scan_returns_arp_only_on_failure(): + from app.services.scanner import _nmap_port_scan + + alive = { + "192.168.1.20": {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}, + } + + mock_nm = MagicMock() + mock_nm.scan.side_effect = Exception("nmap error") + + with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm): + result = _nmap_port_scan(alive) + + # Should return the ARP-found host even though port scan failed + assert len(result) == 1 + assert result[0]["ip"] == "192.168.1.20" + assert result[0]["open_ports"] == [] + + +def test_nmap_port_scan_includes_hosts_with_no_open_ports(): + """IoT devices found by ARP but with no open TCP ports must still be returned.""" + from app.services.scanner import _nmap_port_scan + + alive = { + "192.168.1.30": {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}, + "192.168.1.31": {"ip": "192.168.1.31", "hostname": None, "mac": None, "os": None, "open_ports": []}, + } + + # Port scan returns only 192.168.1.31 (e.g., .30 filtered all ports) + mock_nm = MagicMock() + mock_nm.all_hosts.return_value = ["192.168.1.31"] + mock_nm.__getitem__ = lambda self, host: MagicMock( + all_protocols=lambda: [], + get=lambda key, default=None: default, + ) + + with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \ + patch("app.services.scanner._extract_os", return_value=None): + result = _nmap_port_scan(alive) + + ips = {h["ip"] for h in result} + assert "192.168.1.30" in ips, "ARP-found device with no open ports must still be returned" + assert "192.168.1.31" in ips + + +# --------------------------------------------------------------------------- +# _nmap_scan (integration of both phases) +# --------------------------------------------------------------------------- + +def test_nmap_scan_uses_mock_when_nmap_unavailable(): + from app.services.scanner import _nmap_scan + + with patch("app.services.scanner._NMAP_AVAILABLE", False): + result = _nmap_scan("192.168.1.0/24") + + assert len(result) == 1 + assert result[0]["ip"] == "192.168.1.99" + + +# --------------------------------------------------------------------------- +# _mdns_discover +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_mdns_discover_returns_empty_when_zeroconf_unavailable(): + from app.services.scanner import _mdns_discover + + with patch("app.services.scanner._ZEROCONF_AVAILABLE", False): + result = await _mdns_discover() + + assert result == [] + + +@pytest.mark.asyncio +async def test_mdns_discover_returns_devices(): + from app.services.scanner import _mdns_discover + + mock_info = MagicMock() + mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80 + mock_info.server = "shelly1.local." + mock_info.port = 80 + mock_info.async_request = AsyncMock(return_value=True) + + mock_browser = AsyncMock() + mock_browser.async_cancel = AsyncMock() + + # Simulate a service being found during the sleep + captured_handler: list = [] + + def fake_browser(zc, types, handlers): + captured_handler.extend(handlers) + return mock_browser + + from zeroconf import ServiceStateChange + + async def fake_sleep(t): + # Fire the handler as if a device was discovered + for h in captured_handler: + h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added) + + mock_azc = AsyncMock() + mock_azc.__aenter__ = AsyncMock(return_value=mock_azc) + mock_azc.__aexit__ = AsyncMock(return_value=None) + mock_azc.zeroconf = MagicMock() + + with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \ + patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \ + patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \ + patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \ + patch("asyncio.sleep", side_effect=fake_sleep): + result = await _mdns_discover(timeout=0.01) + + assert len(result) == 1 + assert result[0]["ip"] == "192.168.1.80" + assert result[0]["hostname"] == "shelly1.local." + + +# --------------------------------------------------------------------------- +# run_scan integration +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_run_scan_adds_nmap_devices_as_pending(mem_db): + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + result = await session.execute( + __import__("sqlalchemy", fromlist=["select"]).select(PendingDevice) + ) + devices = result.scalars().all() + + assert any(d.ip == "192.168.1.5" for d in devices) + + +@pytest.mark.asyncio +async def test_run_scan_mdns_only_device_added(mem_db): + """Devices found only by mDNS (not nmap) should appear in pending_devices.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=[]), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + from sqlalchemy import select as sa_select + result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80")) + device = result.scalar_one_or_none() + + assert device is not None + assert device.status == "pending" + + +@pytest.mark.asyncio +async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db): + """If nmap and mDNS both find the same IP, it should not be double-counted.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []} + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + from sqlalchemy import select as sa_select + result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10")) + devices = result.scalars().all() + + assert len(devices) == 1 # not duplicated