Files
homelable/backend/app/services/scanner.py
T
Pouzor 0bd714a68b feat: Phase 3 & 4 — monitoring, discovery, polish, deployment
Phase 3 — Discovery & Monitoring:
- Network scanner: nmap wrapper + mock fallback, fingerprint service (35 signatures)
- Status checker: ping/http/https/tcp/ssh/prometheus/health per-node checks
- APScheduler: status checks every 60s, WebSocket broadcast
- WebSocket /ws/status: live node status updates to frontend
- Sidebar panels: Pending Devices, Hidden Devices, Scan History
- Auth token persisted to localStorage (survive page refresh)
- 24 new backend tests (scan flow + status_checker)

Phase 4 — Polish & Deployment:
- Auto-layout: Dagre hierarchical TB via Toolbar button
- Export PNG: html-to-image download via Toolbar button
- Scan config modal: CIDR ranges + check interval, GET/POST /api/v1/scan/config
- Dockerfile.backend (Python 3.13 slim + nmap), Dockerfile.frontend (nginx)
- docker-compose.yml with data volume and NET_RAW cap for ping
- scripts/lxc-install.sh: Proxmox VE systemd bootstrap
- README.md: quick-start, config reference, stack overview
2026-03-07 00:45:50 +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)
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()