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
This commit is contained in:
@@ -1,22 +1,54 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import PendingDevice, ScanRun
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
|
||||
class ScanConfig(BaseModel):
|
||||
ranges: list[str]
|
||||
interval_seconds: int
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_ranges() -> list[str]:
|
||||
try:
|
||||
with open(settings.config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
return cfg.get("scanner", {}).get("ranges", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await run_scan(ranges, db, run_id)
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=ScanRunResponse)
|
||||
async def trigger_scan(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
run = ScanRun(status="running", ranges=[])
|
||||
async def trigger_scan(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
):
|
||||
ranges = _load_ranges()
|
||||
run = ScanRun(status="running", ranges=ranges)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
# TODO: launch scanner in background thread
|
||||
background_tasks.add_task(_background_scan, run.id, ranges)
|
||||
return run
|
||||
|
||||
|
||||
@@ -26,13 +58,27 @@ async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve")
|
||||
async def approve_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||
async def approve_device(
|
||||
device_id: str,
|
||||
node_data: NodeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
):
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if device:
|
||||
device.status = "approved"
|
||||
node = Node(**node_data.model_dump())
|
||||
db.add(node)
|
||||
await db.commit()
|
||||
return {"approved": True}
|
||||
return {"approved": True, "node_id": node.id}
|
||||
return {"approved": False}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/hide")
|
||||
@@ -42,3 +88,44 @@ async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str
|
||||
device.status = "hidden"
|
||||
await db.commit()
|
||||
return {"hidden": True}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/ignore")
|
||||
async def ignore_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if device:
|
||||
await db.delete(device)
|
||||
await db.commit()
|
||||
return {"ignored": True}
|
||||
|
||||
|
||||
@router.get("/runs", response_model=list[ScanRunResponse])
|
||||
async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)):
|
||||
try:
|
||||
with open(settings.config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
ranges = cfg.get("scanner", {}).get("ranges", [])
|
||||
interval = int(cfg.get("status_checker", {}).get("interval_seconds", 60))
|
||||
return ScanConfig(ranges=ranges, interval_seconds=interval)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/config", response_model=ScanConfig)
|
||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)):
|
||||
try:
|
||||
with open(settings.config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
cfg.setdefault("scanner", {})["ranges"] = payload.ranges
|
||||
cfg.setdefault("status_checker", {})["interval_seconds"] = payload.interval_seconds
|
||||
with open(settings.config_path, "w") as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""APScheduler setup for background scan and status check jobs."""
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import yaml
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal
|
||||
from app.db.models import Node
|
||||
from app.services.status_checker import check_node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _run_status_checks() -> None:
|
||||
"""Check all nodes and broadcast results via WebSocket."""
|
||||
from app.api.routes.status import broadcast_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
|
||||
for node in nodes:
|
||||
if not node.check_method:
|
||||
continue
|
||||
try:
|
||||
result = await check_node(node.check_method, node.check_target, node.ip)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = await db.get(Node, node.id)
|
||||
if n:
|
||||
n.status = result["status"]
|
||||
n.response_time_ms = result["response_time_ms"]
|
||||
n.last_seen = datetime.now(UTC) if result["status"] == "online" else n.last_seen
|
||||
await db.commit()
|
||||
await broadcast_status(
|
||||
node_id=node.id,
|
||||
status=result["status"],
|
||||
checked_at=datetime.now(UTC).isoformat(),
|
||||
response_time_ms=result["response_time_ms"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Status check failed for node %s: %s", node.id, exc)
|
||||
|
||||
|
||||
def _load_interval() -> int:
|
||||
try:
|
||||
with open(settings.config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
return int(cfg.get("status_checker", {}).get("interval_seconds", 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
interval = _load_interval()
|
||||
scheduler.add_job(_run_status_checks, "interval", seconds=interval, id="status_checks")
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", interval)
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
@@ -5,13 +5,16 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, nodes, scan, status
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.db.database import init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
start_scheduler()
|
||||
yield
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Match nmap scan results against service_signatures.json."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SIGNATURES: list[dict] | None = None
|
||||
|
||||
|
||||
def _load() -> list[dict]:
|
||||
global _SIGNATURES
|
||||
if _SIGNATURES is None:
|
||||
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
|
||||
with open(path) as f:
|
||||
_SIGNATURES = json.load(f)
|
||||
return _SIGNATURES
|
||||
|
||||
|
||||
def match_port(port: int, protocol: str, banner: str | None = None) -> dict | None:
|
||||
"""Return the first signature matching port+protocol, optionally banner."""
|
||||
for sig in _load():
|
||||
if sig["port"] != port or sig["protocol"] != protocol:
|
||||
continue
|
||||
if sig.get("banner_regex") and banner and not re.search(sig["banner_regex"], banner, re.IGNORECASE):
|
||||
continue
|
||||
return sig
|
||||
return None
|
||||
|
||||
|
||||
def fingerprint_ports(open_ports: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Given a list of {port, protocol, banner?} dicts, return matched services.
|
||||
Unknown ports are included as unknown_service.
|
||||
"""
|
||||
results = []
|
||||
for p in open_ports:
|
||||
sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
|
||||
if sig:
|
||||
results.append({
|
||||
"port": p["port"],
|
||||
"protocol": p.get("protocol", "tcp"),
|
||||
"service_name": sig["service_name"],
|
||||
"icon": sig.get("icon"),
|
||||
"category": sig.get("category"),
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"port": p["port"],
|
||||
"protocol": p.get("protocol", "tcp"),
|
||||
"service_name": "unknown_service",
|
||||
"icon": None,
|
||||
"category": None,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def suggest_node_type(open_ports: list[dict]) -> str:
|
||||
"""Suggest a node type based on the most specific matched signature."""
|
||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "iot", "switch"]
|
||||
found: set[str] = set()
|
||||
for p in open_ports:
|
||||
sig = match_port(p["port"], p.get("protocol", "tcp"))
|
||||
if sig and sig.get("suggested_node_type"):
|
||||
found.add(sig["suggested_node_type"])
|
||||
for t in priority:
|
||||
if t in found:
|
||||
return t
|
||||
return "generic"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Per-node status checks: ping, http, https, tcp, ssh, prometheus, health."""
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def check_node(check_method: str, target: str | None, ip: str | None) -> dict:
|
||||
"""
|
||||
Run the appropriate check and return {status, response_time_ms}.
|
||||
status is one of: online, offline, unknown.
|
||||
"""
|
||||
host = target or ip
|
||||
if not host:
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
match check_method:
|
||||
case "ping":
|
||||
ok = await _ping(host)
|
||||
case "http":
|
||||
url = host if host.startswith("http") else f"http://{host}"
|
||||
ok = await _http_get(url)
|
||||
case "https":
|
||||
url = host if host.startswith("https") else f"https://{host}"
|
||||
ok = await _http_get(url, verify=True)
|
||||
case "tcp":
|
||||
host_part, _, port_str = host.rpartition(":")
|
||||
port = int(port_str) if port_str.isdigit() else 80
|
||||
ok = await _tcp_connect(host_part or host, port)
|
||||
case "ssh":
|
||||
ok = await _tcp_connect(host, 22)
|
||||
case "prometheus":
|
||||
url = host if host.startswith("http") else f"http://{host}/metrics"
|
||||
ok = await _http_get(url)
|
||||
case "health":
|
||||
url = host if host.startswith("http") else f"http://{host}/health"
|
||||
ok = await _http_get(url)
|
||||
case _:
|
||||
ok = await _ping(host)
|
||||
|
||||
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||
return {"status": "online" if ok else "offline", "response_time_ms": elapsed_ms}
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("Check failed for %s (%s): %s", host, check_method, exc)
|
||||
return {"status": "offline", "response_time_ms": None}
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ping", "-c", "1", "-W", "1", host,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
async def _http_get(url: str, verify: bool = False) -> bool:
|
||||
async with httpx.AsyncClient(verify=verify, timeout=5) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
return resp.status_code < 500
|
||||
|
||||
|
||||
async def _tcp_connect(host: str, port: int) -> bool:
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=3
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return True
|
||||
except (TimeoutError, OSError, socket.gaierror):
|
||||
return False
|
||||
Reference in New Issue
Block a user