diff --git a/.env.example b/.env.example
index 52fa748..1f2b9b9 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,12 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
+# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
+# custom ports. Defaults below are overridable per-scan from the scan dialog.
+SCANNER_HTTP_RANGES=[]
+SCANNER_HTTP_PROBE_ENABLED=false
+SCANNER_HTTP_VERIFY_TLS=false
+
# Status checker interval in seconds
STATUS_CHECKER_INTERVAL=60
diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py
index 390c86a..7f0da05 100644
--- a/backend/app/api/routes/scan.py
+++ b/backend/app/api/routes/scan.py
@@ -14,7 +14,7 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
-from app.services.scanner import request_cancel, run_scan
+from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import build_zigbee_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
@@ -52,8 +52,20 @@ class BulkActionRequest(BaseModel):
device_ids: list[str]
+def _check_port_ranges(v: list[str]) -> list[str]:
+ for r in v:
+ if not _valid_port_range(r.strip()):
+ raise ValueError(f"Invalid port range: {r!r}")
+ return v
+
+
class ScanConfig(BaseModel):
+ """Persisted scan defaults (Options page). Deep-scan fields are optional."""
+
ranges: list[str]
+ http_ranges: list[str] = []
+ http_probe_enabled: bool = False
+ verify_tls: bool = False
@field_validator("ranges")
@classmethod
@@ -65,15 +77,35 @@ class ScanConfig(BaseModel):
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
+ @field_validator("http_ranges")
+ @classmethod
+ def validate_http_ranges(cls, v: list[str]) -> list[str]:
+ return _check_port_ranges(v)
+
+
+class TriggerScanRequest(BaseModel):
+ """Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
+
+ http_ranges: list[str] | None = None
+ http_probe_enabled: bool | None = None
+ verify_tls: bool | None = None
+
+ @field_validator("http_ranges")
+ @classmethod
+ def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
+ return None if v is None else _check_port_ranges(v)
+
logger = logging.getLogger(__name__)
router = APIRouter()
-async def _background_scan(run_id: str, ranges: list[str]) -> None:
+async def _background_scan(
+ run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
+) -> None:
async with AsyncSessionLocal() as db:
try:
- await run_scan(ranges, db, run_id)
+ await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
@@ -83,18 +115,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None:
await db.commit()
+def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
+ """Merge per-scan overrides over persisted settings defaults."""
+ p = payload or TriggerScanRequest()
+ return DeepScanOptions(
+ http_ranges=(
+ p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
+ ),
+ http_probe_enabled=(
+ p.http_probe_enabled
+ if p.http_probe_enabled is not None
+ else settings.scanner_http_probe_enabled
+ ),
+ verify_tls=(
+ p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
+ ),
+ )
+
+
@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(
background_tasks: BackgroundTasks,
+ payload: TriggerScanRequest | None = None,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
ranges = settings.scanner_ranges
+ deep_scan = _resolve_deep_scan(payload)
run = ScanRun(status="running", ranges=ranges)
db.add(run)
await db.commit()
await db.refresh(run)
- background_tasks.add_task(_background_scan, run.id, ranges)
+ background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
return run
@@ -117,10 +169,60 @@ async def stop_scan(
return {"stopping": True}
+async def _canvas_counts(
+ db: AsyncSession, devices: list[PendingDevice]
+) -> dict[str, int]:
+ """Map each device id → number of distinct canvases (designs) it appears on.
+
+ Correlates a scanned device to existing nodes by ``ieee_address`` (exact) or
+ ``ip`` (exact). Runs a single node query and groups in Python — node counts are
+ small for a homelab, so this avoids an N+1 per device.
+ """
+ if not devices:
+ return {}
+ rows = (
+ await db.execute(
+ select(Node.ip, Node.ieee_address, Node.design_id).where(
+ Node.design_id.isnot(None)
+ )
+ )
+ ).all()
+ # ip → set(design_id), ieee → set(design_id)
+ by_ip: dict[str, set[str]] = {}
+ by_ieee: dict[str, set[str]] = {}
+ for ip, ieee, design_id in rows:
+ if ip:
+ by_ip.setdefault(ip, set()).add(design_id)
+ if ieee:
+ by_ieee.setdefault(ieee, set()).add(design_id)
+
+ counts: dict[str, int] = {}
+ for d in devices:
+ designs: set[str] = set()
+ if d.ieee_address:
+ designs |= by_ieee.get(d.ieee_address, set())
+ if d.ip:
+ designs |= by_ip.get(d.ip, set())
+ counts[d.id] = len(designs)
+ return counts
+
+
+async def _with_canvas_counts(
+ db: AsyncSession, devices: list[PendingDevice]
+) -> list[PendingDevice]:
+ """Attach a transient ``canvas_count`` to each device for the response schema."""
+ counts = await _canvas_counts(db, devices)
+ for d in devices:
+ d.canvas_count = counts.get(d.id, 0)
+ return devices
+
+
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
- result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
- return list(result.scalars().all())
+ # Inventory: every scanned device except the user-hidden ones. Approved devices
+ # stay listed so they keep showing with a canvas-presence badge.
+ result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
+ return await _with_canvas_counts(db, list(result.scalars().all()))
@router.delete("/pending", response_model=dict)
@@ -137,7 +239,7 @@ async def clear_pending(
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
- return list(result.scalars().all())
+ return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending/bulk-approve", response_model=dict)
@@ -427,17 +529,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
- return ScanConfig(ranges=settings.scanner_ranges)
+ return ScanConfig(
+ ranges=settings.scanner_ranges,
+ http_ranges=settings.scanner_http_ranges,
+ http_probe_enabled=settings.scanner_http_probe_enabled,
+ verify_tls=settings.scanner_http_verify_tls,
+ )
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
- previous = settings.scanner_ranges
+ previous = (
+ settings.scanner_ranges,
+ settings.scanner_http_ranges,
+ settings.scanner_http_probe_enabled,
+ settings.scanner_http_verify_tls,
+ )
settings.scanner_ranges = payload.ranges
+ settings.scanner_http_ranges = payload.http_ranges
+ settings.scanner_http_probe_enabled = payload.http_probe_enabled
+ settings.scanner_http_verify_tls = payload.verify_tls
try:
settings.save_overrides()
return payload
except Exception as exc:
- settings.scanner_ranges = previous
+ (
+ settings.scanner_ranges,
+ settings.scanner_http_ranges,
+ settings.scanner_http_probe_enabled,
+ settings.scanner_http_verify_tls,
+ ) = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index f599f74..b9f4016 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -48,6 +48,12 @@ class Settings(BaseSettings):
# Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
+ # Deep scan — persisted defaults (overridable per-scan from the scan dialog).
+ # http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
+ scanner_http_ranges: list[str] = []
+ scanner_http_probe_enabled: bool = False
+ scanner_http_verify_tls: bool = False
+
# Status checker
status_checker_interval: int = 60
@@ -85,6 +91,12 @@ class Settings(BaseSettings):
self.service_check_enabled = bool(data["service_check_enabled"])
if "service_check_interval" in data:
self.service_check_interval = int(data["service_check_interval"])
+ if "scanner_http_ranges" in data:
+ self.scanner_http_ranges = list(data["scanner_http_ranges"])
+ if "scanner_http_probe_enabled" in data:
+ self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
+ if "scanner_http_verify_tls" in data:
+ self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
except Exception:
pass
@@ -96,6 +108,9 @@ class Settings(BaseSettings):
"status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
+ "scanner_http_ranges": self.scanner_http_ranges,
+ "scanner_http_probe_enabled": self.scanner_http_probe_enabled,
+ "scanner_http_verify_tls": self.scanner_http_verify_tls,
}))
diff --git a/backend/app/data/service_signatures.json b/backend/app/data/service_signatures.json
index 412b14c..54821fb 100644
--- a/backend/app/data/service_signatures.json
+++ b/backend/app/data/service_signatures.json
@@ -142,5 +142,69 @@
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
- {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
+ {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
+
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
+ {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
]
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index bdcd6d2..1e49386 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -117,6 +117,10 @@ class PendingDevice(Base):
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ # Transient (not persisted): populated per-request by the scan routes to report
+ # how many canvases this device already appears on. Not a mapped column.
+ canvas_count: int = 0
+
class PendingDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py
index 92151bf..b0f5b78 100644
--- a/backend/app/schemas/scan.py
+++ b/backend/app/schemas/scan.py
@@ -21,6 +21,9 @@ class PendingDeviceResponse(BaseModel):
vendor: str | None = None
lqi: int | None = None
discovered_at: datetime
+ # Number of distinct canvases (designs) this device already appears on,
+ # correlated by ip / ieee_address against existing nodes. Computed per-request.
+ canvas_count: int = 0
model_config = {"from_attributes": True}
diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py
index 460bdf3..4532644 100644
--- a/backend/app/services/fingerprint.py
+++ b/backend/app/services/fingerprint.py
@@ -50,25 +50,101 @@ def _load_oui() -> dict[str, str]:
return _OUI_MAP
-def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
- """Return the first signature matching port+protocol, optionally banner."""
- for sig in _load():
- if sig["port"] != port or sig["protocol"] != protocol:
- continue
- if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
- continue
- return sig
- return None
+def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
+ """True when the signature's http_regex matches the probe's title/headers."""
+ rx = sig.get("http_regex")
+ if not rx or not http_signals:
+ return False
+ headers = http_signals.get("headers") or {}
+ haystack = " ".join(
+ s for s in (
+ http_signals.get("title"),
+ headers.get("Server"),
+ headers.get("X-Powered-By"),
+ ) if s
+ )
+ return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
-def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
+def _service_tier(
+ sig: dict[str, Any],
+ port: int,
+ protocol: str,
+ banner: str | None,
+ http_signals: dict[str, Any] | None,
+) -> int | None:
"""
- Given a list of {port, protocol, banner?} dicts, return matched services.
- Unknown ports are included as unknown_service.
+ Rank how well a signature matches (lower = stronger). None = not a match.
+
+ Tier 1: port match + http_regex confirmed
+ Tier 2: port match + banner_regex confirmed
+ Tier 3: port-agnostic (port: null) + http_regex confirmed
+ Tier 4: port match only (no regex, or http_regex with probe disabled)
+
+ When http_signals is None (probe not run) an http_regex entry degrades to
+ a port-only match — identical to pre-probe behaviour, no regression.
+ When http_signals is provided, http_regex is strict: a miss disqualifies.
+ """
+ probe_ran = http_signals is not None
+ has_http = bool(sig.get("http_regex"))
+
+ # Port-agnostic entries (port: null) match purely on HTTP signals.
+ if sig.get("port") is None:
+ if has_http and _http_regex_hit(sig, http_signals):
+ return 3
+ return None
+
+ if sig["port"] != port or sig["protocol"] != protocol:
+ return None
+
+ # http_regex is authoritative once a probe has run.
+ if has_http and probe_ran:
+ return 1 if _http_regex_hit(sig, http_signals) else None
+
+ if sig.get("banner_regex"):
+ if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
+ return 2
+ return None
+
+ # No regex constraint (or http_regex but probe disabled) → port-only guess.
+ return 4
+
+
+def match_service(
+ port: int,
+ protocol: str,
+ banner: str | None = None,
+ http_signals: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ """Return the best signature for a port, walking tiers most-specific first."""
+ best: dict[str, Any] | None = None
+ best_tier = 99
+ for sig in _load():
+ tier = _service_tier(sig, port, protocol, banner, http_signals)
+ if tier is not None and tier < best_tier:
+ best, best_tier = sig, tier
+ if best_tier == 1:
+ break # strongest possible — stop early
+ return best
+
+
+def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
+ """Back-compat alias: match without HTTP-probe signals."""
+ return match_service(port, protocol, banner)
+
+
+def fingerprint_ports(
+ open_ports: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """
+ Given a list of {port, protocol, banner?, http_signals?} dicts, return
+ matched services. Unknown ports are included as unknown_service.
"""
results = []
for p in open_ports:
- sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
+ sig = match_service(
+ p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
+ )
if sig:
results.append({
"port": p["port"],
diff --git a/backend/app/services/http_probe.py b/backend/app/services/http_probe.py
new file mode 100644
index 0000000..8ed9319
--- /dev/null
+++ b/backend/app/services/http_probe.py
@@ -0,0 +1,85 @@
+"""HTTP probe: GET a discovered port and extract identifying signals.
+
+Used by the optional deep-scan mode to confirm what service sits behind an
+open port, regardless of port number. Returns the page
plus a small
+set of identifying response headers, which fingerprint.match_service() then
+matches against signature http_regex fields.
+"""
+import asyncio
+import logging
+import re
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# Headers that commonly carry the application name.
+_SIGNAL_HEADERS = ("Server", "X-Powered-By")
+# Cap how much body we read when hunting for — avoids large downloads.
+_MAX_BODY_BYTES = 64 * 1024
+_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL)
+_PROBE_TIMEOUT = 3.0
+# Ports we never bother probing over HTTP (not web services).
+_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
+
+
+def _extract_title(body: str) -> str | None:
+ m = _TITLE_RE.search(body)
+ if not m:
+ return None
+ title = re.sub(r"\s+", " ", m.group(1)).strip()
+ return title or None
+
+
+async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
+ try:
+ resp = await client.get(url, follow_redirects=True)
+ except (httpx.HTTPError, OSError):
+ return None
+ headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
+ body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
+ title = _extract_title(body)
+ if not title and not headers:
+ return None
+ return {"title": title, "headers": headers}
+
+
+async def probe_port(
+ ip: str, port: int, verify_tls: bool = False
+) -> dict[str, Any] | None:
+ """
+ GET https:// then http:// for a port and return {title, headers} or None.
+
+ None means the port did not answer HTTP or yielded no usable signal.
+ """
+ if port in _NON_HTTP_PORTS:
+ return None
+ async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
+ for scheme in ("https", "http"):
+ result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
+ if result is not None:
+ return result
+ return None
+
+
+async def probe_open_ports(
+ ip: str,
+ open_ports: list[dict[str, Any]],
+ verify_tls: bool = False,
+ concurrency: int = 50,
+) -> list[dict[str, Any]]:
+ """
+ Probe every open port for HTTP signals (option 2: probe all, match after).
+
+ Returns the same port dicts, each enriched with an http_signals key
+ (None when the port gave no HTTP signal).
+ """
+ sem = asyncio.Semaphore(concurrency)
+
+ async def _one(p: dict[str, Any]) -> dict[str, Any]:
+ async with sem:
+ signals = await probe_port(ip, p["port"], verify_tls)
+ return {**p, "http_signals": signals}
+
+ return await asyncio.gather(*(_one(p) for p in open_ports))
diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py
index 4f0ac43..c19d1a5 100644
--- a/backend/app/services/scanner.py
+++ b/backend/app/services/scanner.py
@@ -7,14 +7,16 @@ import re
import socket
import subprocess
import threading
+from dataclasses import dataclass, field
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
+from app.db.models import PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type
+from app.services.http_probe import probe_open_ports
logger = logging.getLogger(__name__)
@@ -34,6 +36,37 @@ _EXTRA_PORTS = (
"16686,34567,37777,51413,64738"
)
+# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
+_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
+
+
+@dataclass
+class DeepScanOptions:
+ """Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
+
+ http_ranges: list[str] = field(default_factory=list)
+ http_probe_enabled: bool = False
+ verify_tls: bool = False
+
+
+def _valid_port_range(spec: str) -> bool:
+ if not _PORT_RANGE_RE.match(spec):
+ return False
+ parts = [int(p) for p in spec.split("-")]
+ if any(p < 1 or p > 65535 for p in parts):
+ return False
+ return len(parts) == 1 or parts[0] <= parts[1]
+
+
+def _build_port_spec(http_ranges: list[str] | None) -> str:
+ """Combine the default port list with validated user ranges for nmap -p."""
+ if not http_ranges:
+ return _EXTRA_PORTS
+ extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
+ if not extra:
+ return _EXTRA_PORTS
+ return _EXTRA_PORTS + "," + ",".join(extra)
+
_MDNS_SERVICE_TYPES = [
"_http._tcp.local.",
"_shelly._tcp.local.",
@@ -195,7 +228,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
return alive
-def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
+def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
@@ -210,11 +243,11 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
- scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
+ scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
- scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
+ scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
@@ -252,7 +285,9 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
return host_dict
-async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
+async def _nmap_port_scan(
+ alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS
+) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
@@ -266,7 +301,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
- return await asyncio.to_thread(_nmap_scan_single, host_dict)
+ return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
@@ -279,7 +314,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
return results
-async def _nmap_scan(target: str) -> list[dict[str, Any]]:
+async def _nmap_scan(target: str, port_spec: str = _EXTRA_PORTS) -> list[dict[str, Any]]:
"""
Two-phase scan for a CIDR range.
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
@@ -296,7 +331,7 @@ async def _nmap_scan(target: str) -> list[dict[str, Any]]:
except Exception as exc:
logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
- return await _nmap_port_scan(alive)
+ return await _nmap_port_scan(alive, port_spec)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
@@ -375,10 +410,50 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
]
-async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
+async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
+ """Collapse duplicate non-hidden inventory rows that share an IP into one.
+
+ Keeps an ``approved`` row when present (it carries canvas-link semantics),
+ otherwise the oldest row, and deletes the rest. Returns the number deleted.
+ """
+ rows = (await db.execute(
+ select(PendingDevice)
+ .where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
+ .order_by(PendingDevice.discovered_at)
+ )).scalars().all()
+
+ by_ip: dict[str, list[PendingDevice]] = {}
+ for row in rows:
+ if row.ip is None: # guarded by the query, but keeps the type checker happy
+ continue
+ by_ip.setdefault(row.ip, []).append(row)
+
+ deleted = 0
+ for group in by_ip.values():
+ if len(group) < 2:
+ continue
+ keep = next((r for r in group if r.status == "approved"), group[0])
+ for dup in group:
+ if dup is not keep:
+ await db.delete(dup)
+ deleted += 1
+ if deleted:
+ await db.commit()
+ return deleted
+
+
+async def run_scan(
+ ranges: list[str],
+ db: AsyncSession,
+ run_id: str,
+ deep_scan: DeepScanOptions | None = None,
+) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices."""
from app.api.routes.status import broadcast_scan_update
+ deep_scan = deep_scan or DeepScanOptions()
+ port_spec = _build_port_spec(deep_scan.http_ranges)
+
devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try:
@@ -389,25 +464,18 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
- # Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
- canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
- canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
-
+ # Pre-fetch hidden IPs once — avoids N+1 queries per host.
+ # Devices already on a canvas are intentionally NOT suppressed: they stay
+ # in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
- # Clean up stale pending devices whose IPs are already in the canvas
- if canvas_ips:
- from sqlalchemy import delete as sa_delete
- await db.execute(
- sa_delete(PendingDevice).where(
- PendingDevice.status == "pending",
- PendingDevice.ip.in_(canvas_ips),
- )
- )
- await db.commit()
+ # Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
+ # left over from older scans, so the device shows up exactly once even if
+ # it isn't re-discovered this run (e.g. now offline).
+ await _dedupe_pending_by_ip(db)
# Start mDNS discovery in the background while nmap scans run
mdns_task = asyncio.create_task(_mdns_discover())
@@ -419,30 +487,48 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
nonlocal devices_found
ip = host["ip"]
- # Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
- if ip in canvas_ips:
- logger.debug("Skipping %s — already in canvas", ip)
- return
+ # Skip only user-hidden devices. On-canvas devices are kept so they
+ # surface in the inventory with a canvas-presence badge.
if ip in hidden_ips:
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",
+ open_ports = host["open_ports"]
+ # Deep-scan HTTP probe: enrich open ports with title/header signals so
+ # fingerprint can confirm services on custom ports. No-op when disabled
+ # or when the host has no open ports (e.g. mDNS-only discovery).
+ if deep_scan.http_probe_enabled and open_ports:
+ open_ports = await probe_open_ports(
+ ip, open_ports, verify_tls=deep_scan.verify_tls
)
- )
- 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
+
+ services = fingerprint_ports(open_ports)
+ suggested_type = suggest_node_type(open_ports, host.get("mac"))
+
+ # One inventory row per device (by IP). Match across pending AND
+ # approved so a re-scan of an already-approved device refreshes its
+ # row instead of spawning a fresh "pending" duplicate. Hidden rows
+ # are already skipped above.
+ existing_rows = (await db.execute(
+ select(PendingDevice)
+ .where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
+ .order_by(PendingDevice.discovered_at)
+ )).scalars().all()
+
+ if existing_rows:
+ # Prefer an approved row (it owns the canvas link semantics),
+ # otherwise the oldest. Collapse any leftover duplicates created
+ # by earlier scans.
+ keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
+ for dup in existing_rows:
+ if dup is not keep:
+ await db.delete(dup)
+ keep.mac = host.get("mac") or keep.mac
+ keep.hostname = host.get("hostname") or keep.hostname
+ keep.os = host.get("os") or keep.os
+ keep.services = services
+ keep.suggested_type = suggested_type
+ # status preserved — an approved device stays approved.
else:
db.add(PendingDevice(
ip=ip,
@@ -463,7 +549,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
for cidr in ranges:
if _is_cancelled(run_id):
break
- hosts = await _nmap_scan(cidr)
+ hosts = await _nmap_scan(cidr, port_spec)
for host in hosts:
if _is_cancelled(run_id):
break
diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py
index 168b437..bdf487e 100644
--- a/backend/tests/test_fingerprint.py
+++ b/backend/tests/test_fingerprint.py
@@ -5,6 +5,7 @@ import pytest
from app.services.fingerprint import (
fingerprint_ports,
match_port,
+ match_service,
suggest_node_type,
suggest_type_from_mac,
)
@@ -249,3 +250,86 @@ def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
mac="24:a4:3c:11:22:33",
)
assert result == "router"
+
+
+# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
+
+HTTP_SIGNATURES = [
+ # Generic web fallback on 8096 (port-only guess)
+ {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
+ "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
+ # Same port, but confirmed by HTML title → should win when probe confirms
+ {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
+ "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
+ # Port-agnostic: matches on HTTP content regardless of port
+ {"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
+ "service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
+ # Banner-based entry, no http
+ {"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
+ "service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
+]
+
+
+@pytest.fixture
+def http_signatures():
+ with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
+ yield
+
+
+def test_http_regex_confirmed_beats_port_only(http_signatures):
+ # Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": "Jellyfin", "headers": {}})
+ assert sig["service_name"] == "Jellyfin"
+
+
+def test_http_regex_matches_on_header(http_signatures):
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
+ assert sig["service_name"] == "Jellyfin"
+
+
+def test_http_regex_miss_falls_back_to_port_only(http_signatures):
+ # Probe ran but nothing matched the http_regex → generic port-only entry wins
+ sig = match_service(8096, "tcp", banner=None,
+ http_signals={"title": "Some Other App", "headers": {}})
+ assert sig["service_name"] == "HTTP"
+
+
+def test_probe_disabled_ignores_http_regex(http_signatures):
+ # http_signals=None (deep scan off) → http_regex entry degrades to port-only,
+ # generic entry (listed first) wins — identical to pre-probe behaviour.
+ sig = match_service(8096, "tcp", banner=None, http_signals=None)
+ assert sig["service_name"] == "HTTP"
+
+
+def test_port_agnostic_match_on_custom_port(http_signatures):
+ # Portainer found on a non-standard port, recognised purely by HTTP content
+ sig = match_service(54321, "tcp", banner=None,
+ http_signals={"title": "Portainer", "headers": {}})
+ assert sig["service_name"] == "Portainer"
+
+
+def test_port_agnostic_requires_probe(http_signatures):
+ # Same custom port, probe off → no signal → no match
+ assert match_service(54321, "tcp", banner=None, http_signals=None) is None
+
+
+def test_banner_match_still_works_with_probe(http_signatures):
+ sig = match_service(9090, "tcp", banner="prometheus 2.x",
+ http_signals={"title": "x", "headers": {}})
+ assert sig["service_name"] == "Prometheus"
+
+
+def test_match_port_alias_has_no_http(http_signatures):
+ # match_port() is the probe-less alias → http_regex entry degrades to port-only
+ sig = match_port(8096, "tcp")
+ assert sig["service_name"] == "HTTP"
+
+
+def test_fingerprint_ports_uses_http_signals(http_signatures):
+ results = fingerprint_ports([
+ {"port": 8096, "protocol": "tcp", "banner": None,
+ "http_signals": {"title": "Jellyfin", "headers": {}}},
+ ])
+ assert results[0]["service_name"] == "Jellyfin"
diff --git a/backend/tests/test_http_probe.py b/backend/tests/test_http_probe.py
new file mode 100644
index 0000000..4175d63
--- /dev/null
+++ b/backend/tests/test_http_probe.py
@@ -0,0 +1,118 @@
+"""Tests for the HTTP probe used by deep-scan service identification."""
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from app.services.http_probe import (
+ _extract_title,
+ probe_open_ports,
+ probe_port,
+)
+
+
+def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
+ return httpx.Response(status_code=status, text=text, headers=headers or {})
+
+
+# ── _extract_title ──────────────────────────────────────────────────────────
+
+def test_extract_title_basic():
+ assert _extract_title("Jellyfin") == "Jellyfin"
+
+
+def test_extract_title_collapses_whitespace():
+ assert _extract_title("\n My App\n") == "My App"
+
+
+def test_extract_title_missing():
+ assert _extract_title("no title") is None
+
+
+def test_extract_title_case_insensitive():
+ assert _extract_title("Portainer") == "Portainer"
+
+
+# ── probe_port ──────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_probe_port_reads_title():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("Jellyfin"))):
+ result = await probe_port("10.0.0.5", 8096)
+ assert result == {"title": "Jellyfin", "headers": {}}
+
+
+@pytest.mark.asyncio
+async def test_probe_port_reads_headers():
+ resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
+ result = await probe_port("10.0.0.5", 3000)
+ assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
+
+
+@pytest.mark.asyncio
+async def test_probe_port_falls_back_to_http():
+ # https raises, http succeeds
+ calls = {"n": 0}
+
+ async def fake_get(self, url, **kw):
+ calls["n"] += 1
+ if url.startswith("https"):
+ raise httpx.ConnectError("tls fail")
+ return _response("HTTP App")
+
+ with patch("httpx.AsyncClient.get", new=fake_get):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result["title"] == "HTTP App"
+ assert calls["n"] == 2 # tried https then http
+
+
+@pytest.mark.asyncio
+async def test_probe_port_no_signal_returns_none():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_probe_port_timeout_returns_none():
+ with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
+ result = await probe_port("10.0.0.5", 8080)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_probe_port_skips_non_http_ports():
+ # SSH should never trigger an HTTP request
+ get = AsyncMock()
+ with patch("httpx.AsyncClient.get", new=get):
+ result = await probe_port("10.0.0.5", 22)
+ assert result is None
+ get.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_probe_port_verify_tls_flag_passed():
+ with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
+ instance = client_cls.return_value.__aenter__.return_value
+ instance.get = AsyncMock(return_value=_response("X"))
+ await probe_port("10.0.0.5", 8443, verify_tls=True)
+ assert client_cls.call_args.kwargs["verify"] is True
+
+
+# ── probe_open_ports ─────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_probe_open_ports_enriches_each_port():
+ async def fake_get(self, url, **kw):
+ if ":8096" in url:
+ return _response("Jellyfin")
+ return _response("")
+
+ ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
+ with patch("httpx.AsyncClient.get", new=fake_get):
+ result = await probe_open_ports("10.0.0.5", ports)
+
+ by_port = {p["port"]: p for p in result}
+ assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
+ assert by_port[9999]["http_signals"] is None
diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py
index 6feb0b9..dc6b518 100644
--- a/backend/tests/test_scan.py
+++ b/backend/tests/test_scan.py
@@ -7,7 +7,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import Node, PendingDevice, ScanRun
+from app.db.models import Design, Node, PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@@ -122,7 +122,8 @@ async def test_background_scan_success_path_invokes_run_scan(mem_db):
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
):
- await _background_scan(run_id, ["10.0.0.0/24"])
+ from app.services.scanner import DeepScanOptions
+ await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
mock_run_scan.assert_awaited_once()
@@ -166,6 +167,66 @@ async def test_list_pending_returns_device(client: AsyncClient, headers, pending
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server"
+ # No matching node → not on any canvas.
+ assert data[0]["canvas_count"] == 0
+
+
+# --- Canvas-presence correlation (canvas_count) ---
+
+async def _add_design(db_session, name: str) -> str:
+ design = Design(id=str(uuid.uuid4()), name=name)
+ db_session.add(design)
+ await db_session.commit()
+ return design.id
+
+
+def _node(design_id: str, *, ip=None, ieee=None) -> Node:
+ return Node(
+ id=str(uuid.uuid4()), label="n", type="server", status="online",
+ ip=ip, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
+ design_id=design_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
+ # Same IP placed on two different canvases → canvas_count == 2.
+ d1 = await _add_design(db_session, "Home")
+ d2 = await _add_design(db_session, "Lab")
+ db_session.add(_node(d1, ip="192.168.1.100"))
+ db_session.add(_node(d2, ip="192.168.1.100"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ data = res.json()
+ assert len(data) == 1
+ assert data[0]["canvas_count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
+ device = PendingDevice(
+ id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
+ suggested_type="zigbee_enddevice", services=[], status="pending",
+ )
+ db_session.add(device)
+ d1 = await _add_design(db_session, "Zigbee")
+ db_session.add(_node(d1, ieee="0x00124b001"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ by_id = {d["id"]: d for d in res.json()}
+ assert by_id[device.id]["canvas_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
+ # A node with no design_id is not "on a canvas".
+ db_session.add(_node(None, ip="192.168.1.100"))
+ await db_session.commit()
+
+ res = await client.get("/api/v1/scan/pending", headers=headers)
+ assert res.json()[0]["canvas_count"] == 0
# --- Approve device ---
@@ -190,9 +251,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
assert data["approved"] is True
assert "node_id" in data
- # Device should no longer appear in pending list
+ # Approved devices stay in the inventory (status != "hidden") so they keep
+ # showing with an "In N canvas" badge — they are no longer dropped.
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
- assert pending_res.json() == []
+ inventory = pending_res.json()
+ assert len(inventory) == 1
+ assert inventory[0]["id"] == pending_device.id
+ assert inventory[0]["status"] == "approved"
@pytest.mark.asyncio
@@ -331,8 +396,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
@pytest.mark.asyncio
-async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
- """Pending devices that were already in canvas before scan starts must be removed."""
+async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
+ """Pending devices whose IP is already on a canvas are NOT purged — they stay
+ in the inventory and are surfaced with an "In N canvas" badge."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
@@ -371,12 +437,13 @@ async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncS
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
- assert result.scalar_one_or_none() is None
+ assert result.scalar_one_or_none() is not None
@pytest.mark.asyncio
-async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
- """Devices whose IP already exists as a canvas Node must not appear in pending."""
+async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
+ """A scanned IP that already exists as a canvas Node still produces a pending
+ device (no longer suppressed)."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
@@ -404,7 +471,62 @@ async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
- assert result.scalar_one_or_none() is None
+ device = result.scalar_one_or_none()
+ assert device is not None
+ assert device.status == "pending"
+
+
+@pytest.mark.asyncio
+async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
+ """Re-scanning an already-approved device updates its row in place instead of
+ spawning a fresh pending duplicate, and keeps it approved."""
+ approved = PendingDevice(
+ id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
+ os=None, services=[], suggested_type="server", status="approved",
+ )
+ db_session.add(approved)
+ run_id = str(uuid.uuid4())
+ db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
+ await db_session.commit()
+
+ with (
+ patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
+ ):
+ await run_scan(["192.168.1.0/24"], db_session, run_id)
+
+ rows = (await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
+ )).scalars().all()
+ assert len(rows) == 1
+ assert rows[0].status == "approved"
+ assert rows[0].hostname == "myhost.lan" # refreshed from the scan
+
+
+@pytest.mark.asyncio
+async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
+ """Pre-existing duplicate inventory rows for one IP are collapsed to a single
+ row at scan start, even if the device is not re-discovered."""
+ for status in ("approved", "pending", "pending"):
+ db_session.add(PendingDevice(
+ id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
+ os=None, services=[], suggested_type="server", status=status,
+ ))
+ run_id = str(uuid.uuid4())
+ db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
+ await db_session.commit()
+
+ with (
+ patch("app.services.scanner._nmap_scan", return_value=[]),
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
+ ):
+ await run_scan(["192.168.1.0/24"], db_session, run_id)
+
+ rows = (await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
+ )).scalars().all()
+ assert len(rows) == 1
+ assert rows[0].status == "approved" # approved row is the one kept
@pytest.mark.asyncio
@@ -518,7 +640,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
call_count = 0
- def nmap_side_effect(target: str):
+ def nmap_side_effect(target: str, port_spec: str | None = None):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
@@ -612,9 +734,11 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
- # Pending list should now be empty
+ # Approved devices stay in the inventory, now marked "approved".
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
- assert pending_res.json() == []
+ inventory = pending_res.json()
+ assert len(inventory) == 2
+ assert all(d["status"] == "approved" for d in inventory)
@pytest.fixture
@@ -1122,3 +1246,114 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert links == [] # consumed
+
+
+# --- Deep scan: trigger overrides + config persistence ---
+
+@pytest.mark.asyncio
+async def test_resolve_deep_scan_falls_back_to_settings():
+ from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_http_ranges = ["7000-7100"]
+ mock_settings.scanner_http_probe_enabled = True
+ mock_settings.scanner_http_verify_tls = False
+ # Empty payload → all values come from settings defaults
+ ds = _resolve_deep_scan(TriggerScanRequest())
+ assert ds.http_ranges == ["7000-7100"]
+ assert ds.http_probe_enabled is True
+ assert ds.verify_tls is False
+
+
+@pytest.mark.asyncio
+async def test_resolve_deep_scan_override_wins():
+ from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ ds = _resolve_deep_scan(
+ TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
+ )
+ assert ds.http_ranges == ["9000"]
+ assert ds.http_probe_enabled is True
+ assert ds.verify_tls is True
+
+
+@pytest.mark.asyncio
+async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
+ captured = {}
+
+ async def fake_bg(run_id, ranges, deep_scan):
+ captured["deep_scan"] = deep_scan
+
+ with (
+ patch("app.api.routes.scan._background_scan", new=fake_bg),
+ patch("app.api.routes.scan.settings") as mock_settings,
+ ):
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ res = await client.post(
+ "/api/v1/scan/trigger",
+ json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ assert captured["deep_scan"].http_probe_enabled is True
+ assert captured["deep_scan"].http_ranges == ["8000-8100"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ res = await client.post(
+ "/api/v1/scan/trigger",
+ json={"http_ranges": ["70000-80000"]},
+ headers=headers,
+ )
+ assert res.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = ["8000-8100"]
+ mock_settings.scanner_http_probe_enabled = True
+ mock_settings.scanner_http_verify_tls = False
+ res = await client.get("/api/v1/scan/config", headers=headers)
+ assert res.status_code == 200
+ data = res.json()
+ assert data["http_ranges"] == ["8000-8100"]
+ assert data["http_probe_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
+ saved = {}
+
+ with patch("app.api.routes.scan.settings") as mock_settings:
+ mock_settings.scanner_ranges = ["192.168.1.0/24"]
+ mock_settings.scanner_http_ranges = []
+ mock_settings.scanner_http_probe_enabled = False
+ mock_settings.scanner_http_verify_tls = False
+ mock_settings.save_overrides = lambda: saved.update(
+ http_ranges=mock_settings.scanner_http_ranges,
+ probe=mock_settings.scanner_http_probe_enabled,
+ )
+ res = await client.post(
+ "/api/v1/scan/config",
+ json={
+ "ranges": ["192.168.1.0/24"],
+ "http_ranges": ["9000-9100"],
+ "http_probe_enabled": True,
+ "verify_tls": True,
+ },
+ headers=headers,
+ )
+ assert res.status_code == 200
+ assert saved == {"http_ranges": ["9000-9100"], "probe": True}
diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py
index e29e2fc..2b02326 100644
--- a/backend/tests/test_scanner.py
+++ b/backend/tests/test_scanner.py
@@ -359,7 +359,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception():
call_count = 0
- def _flaky_scan(host_dict):
+ def _flaky_scan(host_dict, port_spec=None):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
@@ -457,8 +457,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
@pytest.mark.asyncio
-async def test_run_scan_skips_canvas_nodes(mem_db):
- """Hosts already approved onto the canvas must be skipped."""
+async def test_run_scan_keeps_canvas_nodes(mem_db):
+ """Hosts already on a canvas are NOT suppressed — they stay in the inventory
+ (badged "In N canvas" via correlation), so a re-scan still records them."""
from app.services.scanner import run_scan
run_id = _make_run_id()
@@ -481,7 +482,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db):
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
- assert result.scalar_one_or_none() is None
+ device = result.scalar_one_or_none()
+ assert device is not None
+ assert device.status == "pending"
@pytest.mark.asyncio
@@ -533,3 +536,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
+
+
+# ---------------------------------------------------------------------------
+# Deep scan: port-range plumbing + HTTP probe
+# ---------------------------------------------------------------------------
+
+def test_valid_port_range():
+ from app.services.scanner import _valid_port_range
+
+ assert _valid_port_range("8080")
+ assert _valid_port_range("8000-8100")
+ assert not _valid_port_range("8100-8000") # reversed
+ assert not _valid_port_range("0") # below 1
+ assert not _valid_port_range("70000") # above 65535
+ assert not _valid_port_range("abc")
+ assert not _valid_port_range("80,443") # not a single range
+
+
+def test_build_port_spec_default_when_empty():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ assert _build_port_spec([]) == _EXTRA_PORTS
+ assert _build_port_spec(None) == _EXTRA_PORTS
+
+
+def test_build_port_spec_appends_valid_ranges():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ spec = _build_port_spec(["8000-8100", "9000"])
+ assert spec == _EXTRA_PORTS + ",8000-8100,9000"
+
+
+def test_build_port_spec_drops_invalid_ranges():
+ from app.services.scanner import _EXTRA_PORTS, _build_port_spec
+
+ # invalid entries silently dropped; only valid kept
+ assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
+ assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
+
+
+@pytest.mark.asyncio
+async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
+ from app.services.scanner import DeepScanOptions, run_scan
+
+ run_id = _make_run_id()
+ async with mem_db() as session:
+ session.add(_make_scan_run(run_id))
+ await session.commit()
+
+ captured = {}
+
+ async def fake_nmap(target, port_spec):
+ captured["port_spec"] = port_spec
+ return []
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
+ 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,
+ deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
+ )
+
+ assert "8000-8100" in captured["port_spec"]
+
+
+@pytest.mark.asyncio
+async def test_run_scan_probe_enriches_services(mem_db):
+ """With probe enabled, a custom-port service is identified via HTTP signals."""
+ from app.services.scanner import DeepScanOptions, 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.50", "hostname": None, "mac": None, "os": None,
+ "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
+ }]
+ jellyfin_sig = [{
+ "port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
+ "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
+ }]
+
+ async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
+ return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
+ patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
+ patch("app.services.scanner.probe_open_ports", new=fake_probe), \
+ patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
+ await run_scan(
+ ["192.168.1.0/24"], session, run_id,
+ deep_scan=DeepScanOptions(http_probe_enabled=True),
+ )
+
+ async with mem_db() as session:
+ result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
+ device = result.scalar_one_or_none()
+
+ assert device is not None
+ assert any(s["service_name"] == "Jellyfin" for s in device.services)
+
+
+@pytest.mark.asyncio
+async def test_run_scan_no_probe_when_disabled(mem_db):
+ """Probe must not be called on a standard (non-deep) scan."""
+ 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.51", "hostname": None, "mac": None, "os": None,
+ "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
+ }]
+ probe = AsyncMock()
+
+ async with mem_db() as session:
+ with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
+ patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
+ patch("app.services.scanner.probe_open_ports", new=probe), \
+ patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
+ await run_scan(["192.168.1.0/24"], session, run_id)
+
+ probe.assert_not_called()
diff --git a/backend/tests/test_signatures_data.py b/backend/tests/test_signatures_data.py
new file mode 100644
index 0000000..4d34221
--- /dev/null
+++ b/backend/tests/test_signatures_data.py
@@ -0,0 +1,63 @@
+"""Integrity + matching tests against the real service_signatures.json."""
+import re
+
+import pytest
+
+from app.services.fingerprint import _load, match_service
+
+_NODE_TYPES = {
+ "isp", "router", "switch", "server", "proxmox", "vm", "lxc",
+ "nas", "iot", "ap", "camera", "generic",
+}
+
+
+@pytest.fixture
+def signatures():
+ return _load()
+
+
+def test_all_entries_well_formed(signatures):
+ for sig in signatures:
+ # port is an int or explicitly null (port-agnostic)
+ assert sig.get("port") is None or isinstance(sig["port"], int)
+ assert isinstance(sig["service_name"], str) and sig["service_name"]
+ assert sig["suggested_node_type"] in _NODE_TYPES
+ if sig.get("banner_regex"):
+ re.compile(sig["banner_regex"])
+ if sig.get("http_regex"):
+ re.compile(sig["http_regex"])
+
+
+def test_port_agnostic_entries_require_http_regex(signatures):
+ for sig in signatures:
+ if sig.get("port") is None:
+ assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
+
+
+def test_popular_apps_have_port_agnostic_signatures(signatures):
+ names = {s["service_name"] for s in signatures if s.get("port") is None}
+ for expected in {
+ "Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
+ "AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
+ }:
+ assert expected in names, f"missing port-agnostic signature for {expected}"
+
+
+@pytest.mark.parametrize(("title", "expected"), [
+ ("Jellyfin", "Jellyfin"),
+ ("Home Assistant", "Home Assistant"),
+ ("Portainer", "Portainer"),
+ ("Vaultwarden Web Vault", "Vaultwarden"),
+ ("Pi-hole - Dashboard", "Pi-hole"),
+ ("Audiobookshelf", "Audiobookshelf"),
+])
+def test_custom_port_identified_via_http_title(title, expected):
+ # A service on a non-standard port, recognised purely by its HTML title.
+ sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
+ assert sig is not None
+ assert sig["service_name"] == expected
+
+
+def test_custom_port_without_probe_is_unknown():
+ # Same custom port, deep scan off → no signal → no port-agnostic match.
+ assert match_service(58000, "tcp", banner=None, http_signals=None) is None
diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts
index 46de58d..ea434d4 100644
--- a/frontend/src/api/__tests__/client.test.ts
+++ b/frontend/src/api/__tests__/client.test.ts
@@ -170,7 +170,7 @@ describe('api/client', () => {
it('scanApi endpoints route correctly', () => {
mod.scanApi.trigger()
- expect(api.post).toHaveBeenCalledWith('/scan/trigger')
+ expect(api.post).toHaveBeenCalledWith('/scan/trigger', {})
mod.scanApi.pending()
expect(api.get).toHaveBeenCalledWith('/scan/pending')
mod.scanApi.hidden()
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 96a8ebe..22b8e8c 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -58,8 +58,16 @@ export const liveviewApi = {
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
}
+export interface DeepScanConfig {
+ http_ranges: string[]
+ http_probe_enabled: boolean
+ verify_tls: boolean
+}
+
+export type ScanConfigData = { ranges: string[] } & DeepScanConfig
+
export const scanApi = {
- trigger: () => api.post('/scan/trigger'),
+ trigger: (deepScan?: Partial) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
@@ -86,8 +94,8 @@ export const scanApi = {
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
- getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
- saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
+ getConfig: () => api.get('/scan/config'),
+ saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
}
export interface AppSettings {
diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx
index aa76069..542363b 100644
--- a/frontend/src/components/modals/PendingDeviceModal.tsx
+++ b/frontend/src/components/modals/PendingDeviceModal.tsx
@@ -27,6 +27,8 @@ export interface PendingDevice {
vendor?: string | null
lqi?: number | null
discovered_at: string
+ // How many canvases (designs) this device already appears on. Computed server-side.
+ canvas_count?: number
}
interface PendingDeviceModalProps {
diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx
index de35096..eaba4c0 100644
--- a/frontend/src/components/modals/PendingDevicesModal.tsx
+++ b/frontend/src/components/modals/PendingDevicesModal.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
- Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
+ Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client'
@@ -119,6 +119,10 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [sourceFilter, setSourceFilter] = useState('all')
const [typeFilter, setTypeFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState(initialStatus)
+ // Inventory shows on-canvas devices by default; toggle off to hide them.
+ const [showOnCanvas, setShowOnCanvas] = useState(true)
+ // Optionally restrict to devices that have at least one detected service.
+ const [withServicesOnly, setWithServicesOnly] = useState(false)
const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef(null)
@@ -159,6 +163,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
+ // Inventory-only: optionally hide devices already placed on a canvas.
+ if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
+ if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -168,7 +175,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
- }, [devices, search, sourceFilter, typeFilter])
+ }, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -241,9 +248,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else {
+ // Clears only pending rows server-side; approved/on-canvas devices stay,
+ // so reload rather than blanking the whole inventory.
await scanApi.clearPending()
- setDevices([])
setSelectedIds(new Set())
+ await load()
toast.success('Pending devices cleared')
}
} catch {
@@ -398,7 +407,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
- {statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
+ {statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
@@ -479,7 +488,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClick={() => setStatusFilter('pending')}
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
>
- Pending
+ Inventory
+ {statusFilter === 'pending' && (
+
+ )}
+