Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a3e8ea0b1 | |||
| 70907e37bc | |||
| e356d433cb | |||
| cf7777e0af | |||
| 255443b8e1 | |||
| 7e24878077 | |||
| ff1bc7340d | |||
| a7c9abbb9a | |||
| e4bfab7e58 |
@@ -1,4 +1,5 @@
|
|||||||
"""APScheduler setup for background scan and status check jobs."""
|
"""APScheduler setup for background scan and status check jobs."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -15,48 +16,87 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
async def _run_status_checks() -> None:
|
async def _check_single_node(
|
||||||
"""Check all nodes and broadcast results via WebSocket."""
|
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
|
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:
|
async with AsyncSessionLocal() as db:
|
||||||
result = await db.execute(select(Node))
|
result = await db.execute(select(Node))
|
||||||
nodes = result.scalars().all()
|
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 checkable:
|
||||||
if not node.check_method:
|
return
|
||||||
continue
|
|
||||||
try:
|
await asyncio.gather(*[
|
||||||
check_result = await check_node(node.check_method, node.check_target, node.ip)
|
_check_single_node(node_id, method, target, ip)
|
||||||
async with AsyncSessionLocal() as db:
|
for node_id, method, target, ip in checkable
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
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 = 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()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
def reschedule_status_checks(interval_seconds: int) -> None:
|
def reschedule_status_checks(interval_seconds: int) -> None:
|
||||||
"""Update the status check interval on the running scheduler."""
|
"""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:
|
if not scheduler.running:
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return results
|
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] = {
|
_MAC_OUI_TYPES: dict[str, str] = {
|
||||||
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
|
# Hypervisors / VMs
|
||||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
|
"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:50:56": "vm", # VMware
|
||||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||||
"08:00:27": "vm", # VirtualBox
|
"08:00:27": "vm", # VirtualBox
|
||||||
"00:15:5d": "vm", # Hyper-V
|
"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
|
37777: "camera", # Dahua
|
||||||
34567: "camera", # Amcrest
|
34567: "camera", # Amcrest
|
||||||
2020: "camera", # Tapo
|
2020: "camera", # Tapo
|
||||||
# Smart-home / MQTT → iot
|
# Smart-home / MQTT / CoAP → iot
|
||||||
1883: "iot",
|
1883: "iot",
|
||||||
8883: "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
|
# AP / wireless
|
||||||
8880: "ap", # UniFi HTTP
|
8880: "ap", # UniFi HTTP
|
||||||
8443: "ap", # UniFi HTTPS
|
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:
|
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."""
|
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
|
||||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
|
# 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()
|
found: set[str] = set()
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
port = p["port"]
|
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"])
|
found.add(sig["suggested_node_type"])
|
||||||
if port in _PORT_TYPE_HINTS:
|
if port in _PORT_TYPE_HINTS:
|
||||||
found.add(_PORT_TYPE_HINTS[port])
|
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:
|
if mac_type:
|
||||||
found.add(mac_type)
|
found.add(mac_type)
|
||||||
|
|
||||||
for t in priority:
|
for t in priority:
|
||||||
if t in found:
|
if t in found:
|
||||||
return t
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
@@ -15,15 +16,26 @@ logger = logging.getLogger(__name__)
|
|||||||
# Run IDs that have been requested to cancel
|
# Run IDs that have been requested to cancel
|
||||||
_cancelled_runs: set[str] = set()
|
_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:
|
_MDNS_SERVICE_TYPES = [
|
||||||
"""Signal a running scan to stop early."""
|
"_http._tcp.local.",
|
||||||
_cancelled_runs.add(run_id)
|
"_shelly._tcp.local.",
|
||||||
|
"_esphomelib._tcp.local.",
|
||||||
|
"_hap._tcp.local.", # HomeKit Accessory Protocol
|
||||||
def _is_cancelled(run_id: str) -> bool:
|
"_mqtt._tcp.local.",
|
||||||
return run_id in _cancelled_runs
|
"_device-info._tcp.local.",
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nmap
|
import nmap
|
||||||
@@ -32,50 +44,22 @@ except ImportError:
|
|||||||
_NMAP_AVAILABLE = False
|
_NMAP_AVAILABLE = False
|
||||||
logger.warning("python-nmap not available — scanner will run in mock mode")
|
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()
|
def request_cancel(run_id: str) -> None:
|
||||||
try:
|
"""Signal a running scan to stop early."""
|
||||||
# Home lab port range: standard top-1000 + common self-hosted service ports
|
_cancelled_runs.add(run_id)
|
||||||
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
|
|
||||||
|
|
||||||
hosts = []
|
|
||||||
for host in nm.all_hosts():
|
def _is_cancelled(run_id: str) -> bool:
|
||||||
if nm[host].state() != "up":
|
return run_id in _cancelled_runs
|
||||||
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:
|
def _resolve_hostname(ip: str) -> str | None:
|
||||||
@@ -95,6 +79,142 @@ def _extract_os(nm: object, host: str) -> str | None:
|
|||||||
return 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]]:
|
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||||
"""Return fake results for dev/test environments without nmap."""
|
"""Return fake results for dev/test environments without nmap."""
|
||||||
return [
|
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:
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
"""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
|
from app.api.routes.status import broadcast_scan_update
|
||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
try:
|
try:
|
||||||
# Clean up stale pending devices whose IPs are already in the canvas
|
# 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_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:
|
if canvas_ips:
|
||||||
stale_result = await db.execute(
|
stale_result = await db.execute(
|
||||||
select(PendingDevice).where(
|
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.delete(stale)
|
||||||
await db.commit()
|
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:
|
for cidr in ranges:
|
||||||
if _is_cancelled(run_id):
|
if _is_cancelled(run_id):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Run nmap in a thread pool — does not block the event loop
|
|
||||||
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
if _is_cancelled(run_id):
|
if _is_cancelled(run_id):
|
||||||
break
|
break
|
||||||
ip = host["ip"]
|
nmap_ips.add(host["ip"])
|
||||||
|
await _process_host(host)
|
||||||
|
|
||||||
# Skip if device is already in the canvas (approved node)
|
# Collect mDNS results; add devices not already found by nmap
|
||||||
canvas_result = await db.execute(
|
if not _is_cancelled(run_id):
|
||||||
select(Node).where(Node.ip == ip)
|
try:
|
||||||
)
|
mdns_hosts = await asyncio.wait_for(mdns_task, timeout=1.0)
|
||||||
if canvas_result.scalar_one_or_none() is not None:
|
except asyncio.TimeoutError:
|
||||||
logger.debug("Skipping %s — already in canvas", ip)
|
mdns_task.cancel()
|
||||||
continue
|
mdns_hosts = []
|
||||||
|
|
||||||
# Skip if device was explicitly hidden by the user
|
for host in mdns_hosts:
|
||||||
hidden_result = await db.execute(
|
if _is_cancelled(run_id):
|
||||||
select(PendingDevice).where(
|
break
|
||||||
PendingDevice.ip == ip,
|
if host["ip"] in nmap_ips:
|
||||||
PendingDevice.status == "hidden",
|
continue # already processed with richer nmap data
|
||||||
)
|
await _process_host(host)
|
||||||
)
|
else:
|
||||||
if hidden_result.scalar_one_or_none() is not None:
|
mdns_task.cancel()
|
||||||
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)
|
|
||||||
|
|
||||||
# Mark scan as done or cancelled
|
# Mark scan as done or cancelled
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pyyaml==6.0.2
|
|||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
zeroconf==0.131.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
|
|||||||
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
|
|||||||
]):
|
]):
|
||||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||||
assert result == "camera"
|
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",
|
"name": "frontend",
|
||||||
"version": "1.4.0",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.4.0",
|
"version": "1.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -6760,9 +6760,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -258,10 +258,13 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
if (oldEdge) deleteEdge(oldEdge.id)
|
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) {
|
if (newParentId) {
|
||||||
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData>
|
const parentNode = nodes.find((n) => n.id === newParentId)
|
||||||
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
|
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')
|
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', () => {
|
it('deleteNode removes node and its connected edges', () => {
|
||||||
const store = useCanvasStore.getState()
|
const store = useCanvasStore.getState()
|
||||||
store.addNode(makeNode('n1'))
|
store.addNode(makeNode('n1'))
|
||||||
|
|||||||
@@ -187,12 +187,47 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
updateNode: (id, data) =>
|
updateNode: (id, data) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
nodes: state.nodes.map((n) =>
|
let nodes = state.nodes.map((n) => {
|
||||||
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
|
if (n.id !== id) return n
|
||||||
),
|
const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
|
||||||
hasUnsavedChanges: true,
|
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) =>
|
deleteNode: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user