Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a3e8ea0b1 | |||
| 70907e37bc | |||
| e356d433cb | |||
| cf7777e0af | |||
| 255443b8e1 | |||
| 7e24878077 | |||
| ff1bc7340d | |||
| a7c9abbb9a | |||
| e4bfab7e58 | |||
| d5069d9b96 | |||
| 7498a10c14 |
@@ -1,4 +1,5 @@
|
||||
"""APScheduler setup for background scan and status check jobs."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -15,48 +16,87 @@ logger = logging.getLogger(__name__)
|
||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _run_status_checks() -> None:
|
||||
"""Check all nodes and broadcast results via WebSocket."""
|
||||
async def _check_single_node(
|
||||
node_id: str,
|
||||
check_method: str,
|
||||
check_target: str | None,
|
||||
ip: str | None,
|
||||
) -> tuple[str, dict[str, object] | None]:
|
||||
"""Run a single node check; returns (node_id, result_or_None).
|
||||
|
||||
Accepts plain scalars — not an ORM object — so there is no risk of
|
||||
DetachedInstanceError when the originating session has already closed.
|
||||
"""
|
||||
from app.api.routes.status import broadcast_status # avoid circular import
|
||||
|
||||
try:
|
||||
check_result = await check_node(check_method, check_target, ip)
|
||||
now = datetime.now(timezone.utc)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = await db.get(Node, node_id)
|
||||
if n:
|
||||
n.status = check_result["status"]
|
||||
n.response_time_ms = check_result["response_time_ms"]
|
||||
if check_result["status"] == "online":
|
||||
n.last_seen = now
|
||||
await db.commit()
|
||||
await broadcast_status(
|
||||
node_id=node_id,
|
||||
status=check_result["status"],
|
||||
checked_at=now.isoformat(),
|
||||
response_time_ms=check_result["response_time_ms"],
|
||||
)
|
||||
return node_id, check_result
|
||||
except Exception as exc:
|
||||
logger.error("Status check failed for node %s: %s", node_id, exc)
|
||||
return node_id, None
|
||||
|
||||
|
||||
async def _run_status_checks() -> None:
|
||||
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
# Extract scalars while the session is open to avoid DetachedInstanceError
|
||||
checkable = [
|
||||
(n.id, n.check_method, n.check_target, n.ip)
|
||||
for n in nodes
|
||||
if n.check_method
|
||||
]
|
||||
|
||||
for node in nodes:
|
||||
if not node.check_method:
|
||||
continue
|
||||
try:
|
||||
check_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 = check_result["status"]
|
||||
n.response_time_ms = check_result["response_time_ms"]
|
||||
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
|
||||
await db.commit()
|
||||
await broadcast_status(
|
||||
node_id=node.id,
|
||||
status=check_result["status"],
|
||||
checked_at=datetime.now(timezone.utc).isoformat(),
|
||||
response_time_ms=check_result["response_time_ms"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Status check failed for node %s: %s", node.id, exc)
|
||||
if not checkable:
|
||||
return
|
||||
|
||||
await asyncio.gather(*[
|
||||
_check_single_node(node_id, method, target, ip)
|
||||
for node_id, method, target, ip in checkable
|
||||
])
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
try:
|
||||
scheduler.shutdown(wait=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
|
||||
scheduler.add_job(
|
||||
_run_status_checks,
|
||||
"interval",
|
||||
seconds=settings.status_checker_interval,
|
||||
id="status_checks",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
|
||||
def reschedule_status_checks(interval_seconds: int) -> None:
|
||||
"""Update the status check interval on the running scheduler."""
|
||||
if interval_seconds < 10:
|
||||
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
|
||||
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated)
|
||||
# Known OUI prefixes — lowercase, colon-separated, first 3 octets
|
||||
_MAC_OUI_TYPES: dict[str, str] = {
|
||||
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
|
||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
|
||||
# Hypervisors / VMs
|
||||
"52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
|
||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
|
||||
"00:50:56": "vm", # VMware
|
||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||
"08:00:27": "vm", # VirtualBox
|
||||
"00:15:5d": "vm", # Hyper-V
|
||||
# Shelly
|
||||
"34:94:54": "iot",
|
||||
"84:f3:eb": "iot",
|
||||
"ec:fa:bc": "iot",
|
||||
"30:c6:f7": "iot",
|
||||
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
|
||||
"a0:20:a6": "iot",
|
||||
"24:62:ab": "iot",
|
||||
"30:ae:a4": "iot",
|
||||
"cc:50:e3": "iot",
|
||||
"ac:67:b2": "iot",
|
||||
"b4:e6:2d": "iot",
|
||||
"3c:71:bf": "iot",
|
||||
"8c:aa:b5": "iot",
|
||||
# Sonoff / ITEAD
|
||||
"dc:4f:22": "iot",
|
||||
"e8:db:84": "iot",
|
||||
# Tapo / TP-Link smart home
|
||||
"b0:a7:b9": "iot",
|
||||
"50:c7:bf": "iot",
|
||||
"1c:3b:f3": "iot",
|
||||
"10:27:f5": "iot",
|
||||
# Philips Hue
|
||||
"00:17:88": "iot",
|
||||
"ec:b5:fa": "iot",
|
||||
# IKEA Tradfri
|
||||
"34:13:e8": "iot",
|
||||
"00:21:2e": "iot",
|
||||
# Tuya / Smart Life (widely used chip in many brands)
|
||||
"d8:f1:5b": "iot",
|
||||
"68:57:2d": "iot",
|
||||
}
|
||||
|
||||
|
||||
@@ -101,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
||||
37777: "camera", # Dahua
|
||||
34567: "camera", # Amcrest
|
||||
2020: "camera", # Tapo
|
||||
# Smart-home / MQTT → iot
|
||||
# Smart-home / MQTT / CoAP → iot
|
||||
1883: "iot",
|
||||
8883: "iot",
|
||||
6052: "iot", # ESPHome
|
||||
6052: "iot", # ESPHome dashboard
|
||||
4915: "iot", # Shelly CoIoT
|
||||
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
|
||||
5684: "iot", # CoAP DTLS
|
||||
# AP / wireless
|
||||
8880: "ap", # UniFi HTTP
|
||||
8443: "ap", # UniFi HTTPS
|
||||
@@ -115,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
||||
|
||||
|
||||
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
||||
"""Suggest a node type based on matched signatures and MAC OUI."""
|
||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
|
||||
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
|
||||
# IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
|
||||
mac_type = suggest_type_from_mac(mac)
|
||||
if mac_type == "iot":
|
||||
return "iot"
|
||||
|
||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
||||
found: set[str] = set()
|
||||
for p in open_ports:
|
||||
port = p["port"]
|
||||
@@ -126,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
||||
found.add(sig["suggested_node_type"])
|
||||
if port in _PORT_TYPE_HINTS:
|
||||
found.add(_PORT_TYPE_HINTS[port])
|
||||
# MAC OUI is a lower-priority hint — only used if ports give no better answer
|
||||
mac_type = suggest_type_from_mac(mac)
|
||||
|
||||
if mac_type:
|
||||
found.add(mac_type)
|
||||
|
||||
for t in priority:
|
||||
if t in found:
|
||||
return t
|
||||
|
||||
+253
-118
@@ -1,10 +1,11 @@
|
||||
"""Network scanner: ARP sweep + nmap service detection."""
|
||||
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
@@ -15,15 +16,26 @@ logger = logging.getLogger(__name__)
|
||||
# Run IDs that have been requested to cancel
|
||||
_cancelled_runs: set[str] = set()
|
||||
|
||||
# Port list for service detection (Phase 2)
|
||||
_EXTRA_PORTS = (
|
||||
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
||||
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
||||
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
|
||||
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
|
||||
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
|
||||
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
|
||||
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
|
||||
"16686,34567,37777,51413,64738"
|
||||
)
|
||||
|
||||
def request_cancel(run_id: str) -> None:
|
||||
"""Signal a running scan to stop early."""
|
||||
_cancelled_runs.add(run_id)
|
||||
|
||||
|
||||
def _is_cancelled(run_id: str) -> bool:
|
||||
return run_id in _cancelled_runs
|
||||
|
||||
_MDNS_SERVICE_TYPES = [
|
||||
"_http._tcp.local.",
|
||||
"_shelly._tcp.local.",
|
||||
"_esphomelib._tcp.local.",
|
||||
"_hap._tcp.local.", # HomeKit Accessory Protocol
|
||||
"_mqtt._tcp.local.",
|
||||
"_device-info._tcp.local.",
|
||||
]
|
||||
|
||||
try:
|
||||
import nmap
|
||||
@@ -32,50 +44,22 @@ except ImportError:
|
||||
_NMAP_AVAILABLE = False
|
||||
logger.warning("python-nmap not available — scanner will run in mock mode")
|
||||
|
||||
try:
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||
_ZEROCONF_AVAILABLE = True
|
||||
except ImportError:
|
||||
_ZEROCONF_AVAILABLE = False
|
||||
logger.warning("zeroconf not available — mDNS discovery disabled")
|
||||
|
||||
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||
"""Run nmap -sV --open on target, return list of host dicts."""
|
||||
if not _NMAP_AVAILABLE:
|
||||
return _mock_scan(target)
|
||||
|
||||
nm = nmap.PortScanner()
|
||||
try:
|
||||
# Home lab port range: standard top-1000 + common self-hosted service ports
|
||||
extra_ports = (
|
||||
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
||||
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
||||
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
|
||||
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
|
||||
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
|
||||
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
|
||||
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
|
||||
)
|
||||
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
|
||||
except Exception as exc:
|
||||
logger.error("nmap scan failed: %s", exc)
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
def request_cancel(run_id: str) -> None:
|
||||
"""Signal a running scan to stop early."""
|
||||
_cancelled_runs.add(run_id)
|
||||
|
||||
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 _is_cancelled(run_id: str) -> bool:
|
||||
return run_id in _cancelled_runs
|
||||
|
||||
|
||||
def _resolve_hostname(ip: str) -> str | None:
|
||||
@@ -95,6 +79,142 @@ def _extract_os(nm: object, host: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _nmap_arp_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Phase 1: ARP ping sweep — finds ALL alive hosts regardless of open ports.
|
||||
Returns {ip: host_dict} for every host that responds.
|
||||
"""
|
||||
nm = nmap.PortScanner()
|
||||
nm.scan(hosts=target, arguments="-sn -PR -PA80,443 --host-timeout 10s")
|
||||
alive: dict[str, dict[str, Any]] = {}
|
||||
for host in nm.all_hosts():
|
||||
if nm[host].state() == "up":
|
||||
alive[host] = {
|
||||
"ip": host,
|
||||
"hostname": _resolve_hostname(host),
|
||||
"mac": nm[host].get("addresses", {}).get("mac"),
|
||||
"os": None,
|
||||
"open_ports": [],
|
||||
}
|
||||
return alive
|
||||
|
||||
|
||||
def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Phase 2: Service detection on the alive host set from Phase 1.
|
||||
Mutates alive in-place with open_ports/os; returns all hosts including
|
||||
those with zero open ports (IoT devices often have none).
|
||||
"""
|
||||
if not alive:
|
||||
return []
|
||||
nm = nmap.PortScanner()
|
||||
try:
|
||||
nm.scan(
|
||||
hosts=" ".join(alive.keys()),
|
||||
arguments=f"-sV --open -T4 --host-timeout 30s -p {_EXTRA_PORTS}",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Port scan failed, returning ARP-only results: %s", exc)
|
||||
return list(alive.values())
|
||||
|
||||
for host in nm.all_hosts():
|
||||
if host not in alive:
|
||||
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", "")
|
||||
).strip(),
|
||||
})
|
||||
alive[host]["open_ports"] = open_ports
|
||||
if not alive[host]["mac"]:
|
||||
alive[host]["mac"] = nm[host].get("addresses", {}).get("mac")
|
||||
alive[host]["os"] = _extract_os(nm, host)
|
||||
|
||||
return list(alive.values())
|
||||
|
||||
|
||||
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Full two-phase scan for a CIDR range.
|
||||
Phase 1: ARP sweep to find alive hosts (catches IoT with no open ports).
|
||||
Phase 2: Service detection on alive hosts only.
|
||||
"""
|
||||
if not _NMAP_AVAILABLE:
|
||||
return _mock_scan(target)
|
||||
try:
|
||||
alive = _nmap_arp_sweep(target)
|
||||
except Exception as exc:
|
||||
logger.error("nmap ARP sweep failed: %s", exc)
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return _nmap_port_scan(alive)
|
||||
|
||||
|
||||
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Passive mDNS/Bonjour sweep.
|
||||
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
|
||||
Runs for `timeout` seconds then returns what it found.
|
||||
"""
|
||||
if not _ZEROCONF_AVAILABLE:
|
||||
return []
|
||||
|
||||
import ipaddress
|
||||
|
||||
found_services: list[tuple[str, str]] = []
|
||||
|
||||
def _on_change(
|
||||
zeroconf: Any,
|
||||
service_type: str,
|
||||
name: str,
|
||||
state_change: Any,
|
||||
) -> None:
|
||||
if state_change == ServiceStateChange.Added:
|
||||
found_services.append((service_type, name))
|
||||
|
||||
discovered: dict[str, dict[str, Any]] = {}
|
||||
|
||||
try:
|
||||
async with AsyncZeroconf() as azc:
|
||||
browser = AsyncServiceBrowser(
|
||||
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
|
||||
)
|
||||
await asyncio.sleep(timeout)
|
||||
await browser.async_cancel()
|
||||
|
||||
for service_type, name in found_services:
|
||||
try:
|
||||
info = AsyncServiceInfo(service_type, name)
|
||||
await info.async_request(azc.zeroconf, 3000)
|
||||
if not info.addresses:
|
||||
continue
|
||||
ip = str(ipaddress.IPv4Address(info.addresses[0]))
|
||||
if ip in discovered:
|
||||
continue
|
||||
discovered[ip] = {
|
||||
"ip": ip,
|
||||
"hostname": info.server,
|
||||
"mac": None,
|
||||
"os": None,
|
||||
"open_ports": (
|
||||
[{"port": info.port, "protocol": "tcp", "banner": ""}]
|
||||
if info.port else []
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("mDNS resolution failed for %s: %s", name, exc)
|
||||
except Exception as exc:
|
||||
logger.warning("mDNS discovery error: %s", exc)
|
||||
|
||||
logger.info("mDNS discovery found %d device(s)", len(discovered))
|
||||
return list(discovered.values())
|
||||
|
||||
|
||||
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||
"""Return fake results for dev/test environments without nmap."""
|
||||
return [
|
||||
@@ -113,17 +233,13 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||
|
||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||
# Avoid circular import
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
devices_found = 0
|
||||
try:
|
||||
# Clean up stale pending devices whose IPs are already in the canvas
|
||||
# (covers devices approved between scans, or pre-existing canvas nodes)
|
||||
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
||||
canvas_ips = {row[0] for row in canvas_ips_result.fetchall()}
|
||||
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
||||
if canvas_ips:
|
||||
stale_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
@@ -135,78 +251,97 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
await db.delete(stale)
|
||||
await db.commit()
|
||||
|
||||
# Start mDNS discovery in the background while nmap scans run
|
||||
mdns_task: asyncio.Task[list[dict[str, Any]]] = asyncio.create_task(
|
||||
_mdns_discover()
|
||||
)
|
||||
|
||||
# Track IPs found by nmap so mDNS doesn't duplicate them
|
||||
nmap_ips: set[str] = set()
|
||||
|
||||
async def _process_host(host: dict[str, Any]) -> None:
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# Skip canvas nodes and user-hidden devices
|
||||
canvas_result = await db.execute(select(Node).where(Node.ip == ip))
|
||||
if canvas_result.scalar_one_or_none() is not None:
|
||||
logger.debug("Skipping %s — already in canvas", ip)
|
||||
return
|
||||
hidden_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == ip,
|
||||
PendingDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
if hidden_result.scalar_one_or_none() is not None:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
return
|
||||
|
||||
services = fingerprint_ports(host["open_ports"])
|
||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == ip,
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.mac = host.get("mac") or existing.mac
|
||||
existing.hostname = host.get("hostname") or existing.hostname
|
||||
existing.os = host.get("os") or existing.os
|
||||
existing.services = services
|
||||
existing.suggested_type = suggested_type
|
||||
else:
|
||||
db.add(PendingDevice(
|
||||
ip=ip,
|
||||
mac=host.get("mac"),
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
))
|
||||
devices_found += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.devices_found = devices_found
|
||||
await db.commit()
|
||||
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
# nmap scan per CIDR — results stream in progressively
|
||||
for cidr in ranges:
|
||||
if _is_cancelled(run_id):
|
||||
break
|
||||
|
||||
# Run nmap in a thread pool — does not block the event loop
|
||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
||||
|
||||
for host in hosts:
|
||||
if _is_cancelled(run_id):
|
||||
break
|
||||
ip = host["ip"]
|
||||
nmap_ips.add(host["ip"])
|
||||
await _process_host(host)
|
||||
|
||||
# Skip if device is already in the canvas (approved node)
|
||||
canvas_result = await db.execute(
|
||||
select(Node).where(Node.ip == ip)
|
||||
)
|
||||
if canvas_result.scalar_one_or_none() is not None:
|
||||
logger.debug("Skipping %s — already in canvas", ip)
|
||||
continue
|
||||
# Collect mDNS results; add devices not already found by nmap
|
||||
if not _is_cancelled(run_id):
|
||||
try:
|
||||
mdns_hosts = await asyncio.wait_for(mdns_task, timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
mdns_task.cancel()
|
||||
mdns_hosts = []
|
||||
|
||||
# Skip if device was explicitly hidden by the user
|
||||
hidden_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == ip,
|
||||
PendingDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
if hidden_result.scalar_one_or_none() is not None:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
continue
|
||||
|
||||
services = fingerprint_ports(host["open_ports"])
|
||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||
|
||||
# Update existing pending device or create a new one
|
||||
existing_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == ip,
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.mac = host.get("mac") or existing.mac
|
||||
existing.hostname = host.get("hostname") or existing.hostname
|
||||
existing.os = host.get("os") or existing.os
|
||||
existing.services = services
|
||||
existing.suggested_type = suggested_type
|
||||
else:
|
||||
device = PendingDevice(
|
||||
ip=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
|
||||
|
||||
# Commit immediately so the device is visible right away
|
||||
await db.commit()
|
||||
|
||||
# Update running count on the scan run record
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.devices_found = devices_found
|
||||
await db.commit()
|
||||
|
||||
# Push WS event so the frontend refreshes pending panel
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
for host in mdns_hosts:
|
||||
if _is_cancelled(run_id):
|
||||
break
|
||||
if host["ip"] in nmap_ips:
|
||||
continue # already processed with richer nmap data
|
||||
await _process_host(host)
|
||||
else:
|
||||
mdns_task.cancel()
|
||||
|
||||
# Mark scan as done or cancelled
|
||||
run = await db.get(ScanRun, run_id)
|
||||
|
||||
@@ -16,6 +16,7 @@ pyyaml==6.0.2
|
||||
types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.131.0
|
||||
|
||||
# Dev
|
||||
ruff==0.6.9
|
||||
|
||||
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
|
||||
]):
|
||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||
assert result == "camera"
|
||||
|
||||
|
||||
# ── IoT detection ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_suggest_node_type_iot_from_mqtt_port():
|
||||
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_iot_from_coap_port():
|
||||
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_iot_from_esphome_port():
|
||||
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_shelly_mac_overrides_http_port():
|
||||
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
|
||||
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_espressif_mac_returns_iot():
|
||||
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_tuya_mac_returns_iot():
|
||||
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
||||
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
|
||||
result = suggest_node_type([
|
||||
{"port": 80, "protocol": "tcp"},
|
||||
{"port": 1883, "protocol": "tcp"},
|
||||
])
|
||||
assert result == "iot"
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.database import Base
|
||||
from app.db.models import PendingDevice, ScanRun
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_run_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _make_scan_run(run_id: str) -> ScanRun:
|
||||
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_arp_sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_nmap_arp_sweep_returns_alive_hosts():
|
||||
from app.services.scanner import _nmap_arp_sweep
|
||||
|
||||
mock_nm = MagicMock()
|
||||
mock_nm.all_hosts.return_value = ["192.168.1.1", "192.168.1.2"]
|
||||
mock_nm.__getitem__ = lambda self, host: MagicMock(
|
||||
state=lambda: "up",
|
||||
get=lambda key, default=None: {"mac": "aa:bb:cc:dd:ee:ff"} if key == "addresses" else default,
|
||||
)
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||
result = _nmap_arp_sweep("192.168.1.0/24")
|
||||
|
||||
assert set(result.keys()) == {"192.168.1.1", "192.168.1.2"}
|
||||
for host in result.values():
|
||||
assert host["open_ports"] == [] # empty until phase 2
|
||||
|
||||
|
||||
def test_nmap_arp_sweep_skips_down_hosts():
|
||||
from app.services.scanner import _nmap_arp_sweep
|
||||
|
||||
states = {"192.168.1.1": "up", "192.168.1.2": "down"}
|
||||
|
||||
mock_nm = MagicMock()
|
||||
mock_nm.all_hosts.return_value = list(states.keys())
|
||||
|
||||
def getitem(host):
|
||||
m = MagicMock()
|
||||
m.state.return_value = states[host]
|
||||
m.get.return_value = {}
|
||||
return m
|
||||
|
||||
mock_nm.__getitem__ = lambda self, host: getitem(host)
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
||||
result = _nmap_arp_sweep("192.168.1.0/24")
|
||||
|
||||
assert "192.168.1.1" in result
|
||||
assert "192.168.1.2" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_port_scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_nmap_port_scan_merges_ports():
|
||||
from app.services.scanner import _nmap_port_scan
|
||||
|
||||
alive = {
|
||||
"192.168.1.10": {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||
}
|
||||
|
||||
mock_nm = MagicMock()
|
||||
mock_nm.all_hosts.return_value = ["192.168.1.10"]
|
||||
mock_nm.__getitem__ = lambda self, host: MagicMock(
|
||||
all_protocols=lambda: ["tcp"],
|
||||
**{"__getitem__": lambda self2, proto: {
|
||||
80: {"state": "open", "product": "nginx", "version": "1.24"},
|
||||
}},
|
||||
get=lambda key, default=None: default,
|
||||
)
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||
patch("app.services.scanner._extract_os", return_value=None):
|
||||
result = _nmap_port_scan(alive)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["open_ports"][0]["port"] == 80
|
||||
|
||||
|
||||
def test_nmap_port_scan_returns_arp_only_on_failure():
|
||||
from app.services.scanner import _nmap_port_scan
|
||||
|
||||
alive = {
|
||||
"192.168.1.20": {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||
}
|
||||
|
||||
mock_nm = MagicMock()
|
||||
mock_nm.scan.side_effect = Exception("nmap error")
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
||||
result = _nmap_port_scan(alive)
|
||||
|
||||
# Should return the ARP-found host even though port scan failed
|
||||
assert len(result) == 1
|
||||
assert result[0]["ip"] == "192.168.1.20"
|
||||
assert result[0]["open_ports"] == []
|
||||
|
||||
|
||||
def test_nmap_port_scan_includes_hosts_with_no_open_ports():
|
||||
"""IoT devices found by ARP but with no open TCP ports must still be returned."""
|
||||
from app.services.scanner import _nmap_port_scan
|
||||
|
||||
alive = {
|
||||
"192.168.1.30": {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []},
|
||||
"192.168.1.31": {"ip": "192.168.1.31", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
||||
}
|
||||
|
||||
# Port scan returns only 192.168.1.31 (e.g., .30 filtered all ports)
|
||||
mock_nm = MagicMock()
|
||||
mock_nm.all_hosts.return_value = ["192.168.1.31"]
|
||||
mock_nm.__getitem__ = lambda self, host: MagicMock(
|
||||
all_protocols=lambda: [],
|
||||
get=lambda key, default=None: default,
|
||||
)
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
||||
patch("app.services.scanner._extract_os", return_value=None):
|
||||
result = _nmap_port_scan(alive)
|
||||
|
||||
ips = {h["ip"] for h in result}
|
||||
assert "192.168.1.30" in ips, "ARP-found device with no open ports must still be returned"
|
||||
assert "192.168.1.31" in ips
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan (integration of both phases)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_nmap_scan_uses_mock_when_nmap_unavailable():
|
||||
from app.services.scanner import _nmap_scan
|
||||
|
||||
with patch("app.services.scanner._NMAP_AVAILABLE", False):
|
||||
result = _nmap_scan("192.168.1.0/24")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["ip"] == "192.168.1.99"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _mdns_discover
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
|
||||
from app.services.scanner import _mdns_discover
|
||||
|
||||
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
|
||||
result = await _mdns_discover()
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mdns_discover_returns_devices():
|
||||
from app.services.scanner import _mdns_discover
|
||||
|
||||
mock_info = MagicMock()
|
||||
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
|
||||
mock_info.server = "shelly1.local."
|
||||
mock_info.port = 80
|
||||
mock_info.async_request = AsyncMock(return_value=True)
|
||||
|
||||
mock_browser = AsyncMock()
|
||||
mock_browser.async_cancel = AsyncMock()
|
||||
|
||||
# Simulate a service being found during the sleep
|
||||
captured_handler: list = []
|
||||
|
||||
def fake_browser(zc, types, handlers):
|
||||
captured_handler.extend(handlers)
|
||||
return mock_browser
|
||||
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
async def fake_sleep(t):
|
||||
# Fire the handler as if a device was discovered
|
||||
for h in captured_handler:
|
||||
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
|
||||
|
||||
mock_azc = AsyncMock()
|
||||
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
|
||||
mock_azc.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_azc.zeroconf = MagicMock()
|
||||
|
||||
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
|
||||
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
|
||||
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
|
||||
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
|
||||
patch("asyncio.sleep", side_effect=fake_sleep):
|
||||
result = await _mdns_discover(timeout=0.01)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["ip"] == "192.168.1.80"
|
||||
assert result[0]["hostname"] == "shelly1.local."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_scan integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(
|
||||
__import__("sqlalchemy", fromlist=["select"]).select(PendingDevice)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
from sqlalchemy import select as sa_select
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
||||
device = result.scalar_one_or_none()
|
||||
|
||||
assert device is not None
|
||||
assert device.status == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
from sqlalchemy import select as sa_select
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
||||
devices = result.scalars().all()
|
||||
|
||||
assert len(devices) == 1 # not duplicated
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -6760,9 +6760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -258,10 +258,13 @@ export default function App() {
|
||||
)
|
||||
if (oldEdge) deleteEdge(oldEdge.id)
|
||||
}
|
||||
// Create new virtual edge: LXC top → Proxmox bottom
|
||||
// Create virtual edge only when parent is NOT in container mode
|
||||
// (container mode shows containment visually — no edge needed)
|
||||
if (newParentId) {
|
||||
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData>
|
||||
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
||||
const parentNode = nodes.find((n) => n.id === newParentId)
|
||||
if (!parentNode?.data.container_mode) {
|
||||
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,57 @@ describe('canvasStore', () => {
|
||||
expect(node?.data.ip).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('updateNode setting parent_id on container-mode proxmox sets parentId and relative position', () => {
|
||||
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
|
||||
useCanvasStore.getState().addNode(proxmox)
|
||||
useCanvasStore.getState().addNode(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||
expect(node?.parentId).toBe('px1')
|
||||
expect(node?.extent).toBe('parent')
|
||||
// Position should be relative to parent (160-100=60, 180-100=80)
|
||||
expect(node?.position.x).toBe(60)
|
||||
expect(node?.position.y).toBe(80)
|
||||
})
|
||||
|
||||
it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => {
|
||||
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
|
||||
useCanvasStore.getState().addNode(proxmox)
|
||||
useCanvasStore.getState().addNode(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||
expect(node?.parentId).toBeUndefined()
|
||||
expect(node?.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
|
||||
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
|
||||
useCanvasStore.getState().addNode(proxmox)
|
||||
useCanvasStore.getState().addNode(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
|
||||
expect(node?.parentId).toBeUndefined()
|
||||
expect(node?.extent).toBeUndefined()
|
||||
// Position should be absolute (100+30=130, 100+40=140)
|
||||
expect(node?.position.x).toBe(130)
|
||||
expect(node?.position.y).toBe(140)
|
||||
})
|
||||
|
||||
it('updateNode with parent_id puts parents before children in array', () => {
|
||||
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 10, y: 10 } }
|
||||
useCanvasStore.getState().addNode(proxmox)
|
||||
useCanvasStore.getState().addNode(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
const pxIdx = nodes.findIndex((n) => n.id === 'px1')
|
||||
const lxcIdx = nodes.findIndex((n) => n.id === 'lxc1')
|
||||
expect(pxIdx).toBeLessThan(lxcIdx)
|
||||
})
|
||||
|
||||
it('deleteNode removes node and its connected edges', () => {
|
||||
const store = useCanvasStore.getState()
|
||||
store.addNode(makeNode('n1'))
|
||||
|
||||
@@ -187,12 +187,47 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}),
|
||||
|
||||
updateNode: (id, data) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
|
||||
),
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
set((state) => {
|
||||
let nodes = state.nodes.map((n) => {
|
||||
if (n.id !== id) return n
|
||||
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||
if ('parent_id' in data) {
|
||||
const newParentId = data.parent_id ?? undefined
|
||||
if (!newParentId && n.parentId) {
|
||||
// Detaching from a container: convert position back to absolute canvas coords
|
||||
const parent = state.nodes.find((p) => p.id === n.parentId)
|
||||
if (parent) {
|
||||
updated.position = {
|
||||
x: parent.position.x + n.position.x,
|
||||
y: parent.position.y + n.position.y,
|
||||
}
|
||||
}
|
||||
updated.parentId = undefined
|
||||
updated.extent = undefined
|
||||
} else if (newParentId && newParentId !== n.parentId) {
|
||||
const parent = state.nodes.find((p) => p.id === newParentId)
|
||||
if (parent?.data.container_mode) {
|
||||
// Attaching to a container-mode Proxmox: nest visually
|
||||
updated.parentId = newParentId
|
||||
updated.extent = 'parent' as const
|
||||
// Convert absolute position to parent-relative (keep node visible inside)
|
||||
updated.position = {
|
||||
x: Math.max(10, n.position.x - parent.position.x),
|
||||
y: Math.max(10, n.position.y - parent.position.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
})
|
||||
// React Flow requires parent nodes to precede their children in the array
|
||||
if ('parent_id' in data) {
|
||||
const parents = nodes.filter((n) => !n.parentId)
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
nodes = [...parents, ...children]
|
||||
}
|
||||
return { nodes, hasUnsavedChanges: true }
|
||||
}),
|
||||
|
||||
deleteNode: (id) =>
|
||||
set((state) => {
|
||||
|
||||
@@ -30,8 +30,8 @@ info "Detected: $PRETTY_NAME"
|
||||
|
||||
# ── System deps ───────────────────────────────────────────────────────────────
|
||||
info "Installing system dependencies..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-pip python3-venv nmap curl git nginx
|
||||
apt-get update
|
||||
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
|
||||
|
||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
||||
if ! command -v node &>/dev/null; then
|
||||
|
||||
Reference in New Issue
Block a user