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:
Pouzor
2026-03-07 00:45:50 +01:00
parent 44a448e26d
commit 0bd714a68b
26 changed files with 1778 additions and 25 deletions
+96 -9
View File
@@ -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
+66
View File
@@ -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)
+3
View File
@@ -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(
View File
+67
View File
@@ -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"
+138
View File
@@ -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()
+80
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
[
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB", "icon": "share-2", "category": "file-sharing", "suggested_node_type": "nas"},
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "file-sharing", "suggested_node_type": "nas"},
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "file-sharing", "suggested_node_type": "server"},
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter", "icon": "code", "category": "dev", "suggested_node_type": "server"},
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix", "icon": "message-square", "category": "messaging", "suggested_node_type": "server"},
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"}
]
+161
View File
@@ -0,0 +1,161 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.db.models import PendingDevice
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
async def pending_device(db_session):
import uuid
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.100",
mac="aa:bb:cc:dd:ee:ff",
hostname="my-server",
os="Linux",
services=[{"port": 22, "name": "ssh"}],
suggested_type="server",
status="pending",
)
db_session.add(device)
await db_session.commit()
await db_session.refresh(device)
return device
# --- Trigger scan ---
@pytest.mark.asyncio
async def test_trigger_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/trigger")
# FastAPI's OAuth2PasswordBearer returns 403 when no token is provided
assert res.status_code in (401, 403)
@pytest.mark.asyncio
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
with (
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]),
):
res = await client.post("/api/v1/scan/trigger", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["status"] == "running"
assert data["ranges"] == ["192.168.1.0/24"]
assert "id" in data
# --- Pending devices ---
@pytest.mark.asyncio
async def test_list_pending_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
assert res.json() == []
@pytest.mark.asyncio
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server"
# --- Approve device ---
@pytest.mark.asyncio
async def test_approve_device(client: AsyncClient, headers, pending_device):
node_payload = {
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"hostname": "my-server",
"status": "unknown",
"services": [],
}
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert "node_id" in data
# Device should no longer appear in pending list
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
@pytest.mark.asyncio
async def test_approve_nonexistent_device(client: AsyncClient, headers):
node_payload = {
"label": "Ghost",
"type": "generic",
"ip": "10.0.0.1",
"status": "unknown",
"services": [],
}
res = await client.post(
"/api/v1/scan/pending/nonexistent-id/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 200
assert res.json()["approved"] is False
# --- Hide device ---
@pytest.mark.asyncio
async def test_hide_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
assert res.status_code == 200
assert res.json()["hidden"] is True
# Should no longer appear in pending
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
# Should appear in hidden
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 1
# --- Ignore device ---
@pytest.mark.asyncio
async def test_ignore_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
assert res.status_code == 200
assert res.json()["ignored"] is True
# Device should be gone from both pending and hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
# --- Scan runs ---
@pytest.mark.asyncio
async def test_list_runs_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/runs", headers=headers)
assert res.status_code == 200
assert res.json() == []
+162
View File
@@ -0,0 +1,162 @@
"""Tests for status_checker service: each check method."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.status_checker import _tcp_connect, check_node
# --- check_node dispatcher ---
@pytest.mark.asyncio
async def test_check_node_unknown_without_host():
result = await check_node("ping", None, None)
assert result["status"] == "unknown"
assert result["response_time_ms"] is None
@pytest.mark.asyncio
async def test_check_node_ping_online():
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True):
result = await check_node("ping", None, "192.168.1.1")
assert result["status"] == "online"
assert result["response_time_ms"] is not None
@pytest.mark.asyncio
async def test_check_node_ping_offline():
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=False):
result = await check_node("ping", None, "192.168.1.1")
assert result["status"] == "offline"
@pytest.mark.asyncio
async def test_check_node_http_online():
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
result = await check_node("http", "192.168.1.1:8080", None)
assert result["status"] == "online"
@pytest.mark.asyncio
async def test_check_node_http_prepends_scheme():
"""If target doesn't start with http, http:// is prepended."""
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_node("http", "192.168.1.1:8080", None)
assert captured["url"].startswith("http://")
@pytest.mark.asyncio
async def test_check_node_https_uses_verify():
captured = {}
async def fake_http_get(url, verify=False):
captured["verify"] = verify
return True
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_node("https", "https://myserver", None)
assert captured["verify"] is True
@pytest.mark.asyncio
async def test_check_node_ssh():
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock, return_value=True) as mock_tcp:
result = await check_node("ssh", None, "192.168.1.5")
mock_tcp.assert_called_once_with("192.168.1.5", 22)
assert result["status"] == "online"
@pytest.mark.asyncio
async def test_check_node_tcp_parses_port():
captured = {}
async def fake_tcp(host, port):
captured["host"] = host
captured["port"] = port
return True
with patch("app.services.status_checker._tcp_connect", side_effect=fake_tcp):
await check_node("tcp", "192.168.1.10:9090", None)
assert captured["host"] == "192.168.1.10"
assert captured["port"] == 9090
@pytest.mark.asyncio
async def test_check_node_prometheus_appends_metrics():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_node("prometheus", "192.168.1.10:9090", None)
assert "/metrics" in captured["url"]
@pytest.mark.asyncio
async def test_check_node_health_appends_health():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_node("health", "192.168.1.10:8080", None)
assert "/health" in captured["url"]
@pytest.mark.asyncio
async def test_check_node_unknown_method_falls_back_to_ping():
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True) as mock_ping:
result = await check_node("foobar", None, "10.0.0.1")
mock_ping.assert_called_once()
assert result["status"] == "online"
@pytest.mark.asyncio
async def test_check_node_exception_returns_offline():
with patch("app.services.status_checker._ping", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
result = await check_node("ping", None, "10.0.0.1")
assert result["status"] == "offline"
assert result["response_time_ms"] is None
# --- _tcp_connect ---
@pytest.mark.asyncio
async def test_tcp_connect_success():
writer_mock = MagicMock()
writer_mock.close = MagicMock()
writer_mock.wait_closed = AsyncMock()
with patch("asyncio.open_connection", new_callable=AsyncMock, return_value=(MagicMock(), writer_mock)):
result = await _tcp_connect("192.168.1.1", 22)
assert result is True
@pytest.mark.asyncio
async def test_tcp_connect_timeout():
async def timeout_open(*args, **kwargs):
raise TimeoutError()
with patch("asyncio.open_connection", side_effect=timeout_open):
result = await _tcp_connect("192.168.1.1", 22)
assert result is False
@pytest.mark.asyncio
async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999)
assert result is False