fb85468cef
- 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
139 lines
4.3 KiB
Python
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()
|