"""Network scanner: ARP sweep + nmap service detection.""" import logging import socket from datetime import UTC, datetime from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import PendingDevice, ScanRun from app.services.fingerprint import fingerprint_ports, suggest_node_type logger = logging.getLogger(__name__) try: import nmap # type: ignore[import-untyped] _NMAP_AVAILABLE = True except ImportError: _NMAP_AVAILABLE = False logger.warning("python-nmap not available — scanner will run in mock mode") def _nmap_scan(target: str) -> list[dict]: """Run nmap -sV --open on target, return list of host dicts.""" if not _NMAP_AVAILABLE: return _mock_scan(target) nm = nmap.PortScanner() try: nm.scan(hosts=target, arguments="-sV --open -T4 --host-timeout 30s") except Exception as exc: logger.error("nmap scan failed: %s", exc) return [] 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 _resolve_hostname(ip: str) -> str | None: try: return socket.gethostbyaddr(ip)[0] except Exception: return None def _extract_os(nm: object, host: str) -> str | None: try: osmatch = nm[host].get("osmatch", []) # type: ignore[index] if osmatch: return osmatch[0]["name"] except Exception: pass return None def _mock_scan(target: str) -> list[dict]: """Return fake results for dev/test environments without nmap.""" return [ { "ip": "192.168.1.99", "hostname": "unknown-device.lan", "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": [ {"port": 80, "protocol": "tcp", "banner": "nginx"}, {"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.0"}, ], } ] async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: """Execute scan for given CIDR ranges and populate pending_devices.""" devices_found = 0 try: for cidr in ranges: hosts = _nmap_scan(cidr) for host in hosts: services = fingerprint_ports(host["open_ports"]) suggested_type = suggest_node_type(host["open_ports"]) # Skip if already pending or already a node (by IP) existing = await db.execute( __import__("sqlalchemy", fromlist=["select"]).select(PendingDevice).where( PendingDevice.ip == host["ip"], PendingDevice.status == "pending", ) ) if existing.scalar_one_or_none(): continue device = PendingDevice( ip=host["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 await db.commit() # Update scan run run = await db.get(ScanRun, run_id) if run: run.status = "done" run.devices_found = devices_found run.finished_at = datetime.now(UTC) await db.commit() except Exception as exc: logger.error("Scan failed: %s", exc) run = await db.get(ScanRun, run_id) if run: run.status = "error" run.error = str(exc) run.finished_at = datetime.now(UTC) await db.commit()