Files
homelable/backend/app/services/scanner.py
T
Pouzor fb85468cef fix: propagate nmap errors to UI + sudo scan script
- scanner.py: re-raise nmap exceptions so they reach ScanRun.error
  (previously silently returned [] hiding the root cause)
- Sidebar: after triggering scan, auto-switch to History tab
- ScanHistoryPanel: auto-refresh every 3s while any run is 'running';
  show error toast when a run transitions running→error; show spinner
  on running runs; error message fully visible (no truncation)
- scripts/run_scan.py: standalone scan script to run with sudo for
  nmap OS detection / SYN scans on macOS
2026-03-07 01:28:21 +01:00

139 lines
4.3 KiB
Python

"""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)
raise RuntimeError(str(exc)) from exc
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()