Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390a712f14 | ||
|
|
9daea4c4d0 | ||
|
|
2e642815f4 | ||
|
|
c6d6b525ea | ||
|
|
fbb660504e | ||
|
|
1be96d1045 | ||
|
|
9bc02d61c2 | ||
|
|
21a64e52ab |
@@ -5,6 +5,20 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.3.5] - 2026-08-21
|
||||
|
||||
### Features
|
||||
|
||||
- Deep-scan a single device from its inventory detail: the Services section gets a **Deep scan** button that sweeps the device over a port range you pick, prefilled with the full 1-65535 range and narrowable when you know where the service lives. The run is a normal Scan Run, so stop, progress and Scan History work as usual, and the fresh services are merged in — never replacing what you added by hand. (#363)
|
||||
|
||||
### Fixes
|
||||
|
||||
- A deep rescan of a slow host no longer comes back empty. The run hit its timeout and nmap discarded every port it had already found; the full range is now scanned in slices, a partial sweep is reported as partial instead of passed off as complete, and the timeout is a total budget between slices rather than an nmap flag. (#363)
|
||||
- A scan no longer repaints the service icons you picked by hand: the fingerprint's guess overwrote your choice, and cleared it outright on a port no signature covers. Since 3.3.0 the inventory row is the only copy, so one scan changed the service on every canvas at once. (#363)
|
||||
- A rescan finishing while you edit a device no longer throws the edit away, and it no longer tags every device it touches as network-discovered — a Proxmox guest, a rack mount or a hand-added host keeps its own source. A failed run is also recorded as `error`, the status Scan History filters and colours. (#363)
|
||||
- A property with no value gets the full node width for its label instead of being truncated against empty space. (#362)
|
||||
- A same-origin OIDC deployment behind a reverse proxy no longer answers 403 to every write. The CSRF check validated the browser's `Origin` against `CORS_ORIGINS` alone; the app's own origin, taken from `OIDC_REDIRECT_URI`, is accepted too, and startup warns when `CORS_ORIGINS` omits it. (#360)
|
||||
|
||||
## [3.3.4] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -32,7 +32,14 @@ from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device, find_duplicate_node
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.scanner import (
|
||||
DeepScanOptions,
|
||||
_valid_port_range,
|
||||
_valid_port_spec,
|
||||
request_cancel,
|
||||
run_device_scan,
|
||||
run_scan,
|
||||
)
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
merge_zigbee_properties,
|
||||
@@ -176,7 +183,39 @@ async def _background_scan(
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
run.status = "failed"
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _background_device_scan(
|
||||
run_id: str,
|
||||
device_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await run_device_scan(
|
||||
device_id,
|
||||
db,
|
||||
run_id,
|
||||
deep_scan=deep_scan or DeepScanOptions(),
|
||||
full_ports=full_ports,
|
||||
ports=ports,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Device scan run %s failed unexpectedly", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -215,6 +254,78 @@ async def trigger_scan(
|
||||
return run
|
||||
|
||||
|
||||
class RescanDeviceRequest(BaseModel):
|
||||
"""Per-device deep rescan. Defaults to every TCP port — that is the point.
|
||||
|
||||
``ports`` narrows the sweep to what the user typed in the deep-scan dialog
|
||||
(``80,443``, ``1-1024``); it wins over ``full_ports``.
|
||||
"""
|
||||
|
||||
full_ports: bool = True
|
||||
ports: str | None = None
|
||||
http_probe_enabled: bool | None = None
|
||||
verify_tls: bool | None = None
|
||||
|
||||
@field_validator("ports")
|
||||
@classmethod
|
||||
def _check_ports(cls, v: str | None) -> str | None:
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
spec = v.strip()
|
||||
if not _valid_port_spec(spec):
|
||||
raise ValueError("Invalid port range")
|
||||
return spec
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/rescan", response_model=ScanRunResponse)
|
||||
async def rescan_device(
|
||||
device_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: RescanDeviceRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Deep-rescan one known device to refresh its services (issue #350).
|
||||
|
||||
Recorded as a ScanRun like any other scan, so progress, stop and history
|
||||
work unchanged. One run per device at a time — a second request while the
|
||||
first is still scanning is a 409, not a duplicate nmap over 65535 ports.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if not device.ip:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Device has no IP address to scan"
|
||||
)
|
||||
if device.status == "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
||||
|
||||
target = f"{device.ip}/32"
|
||||
running = (await db.execute(
|
||||
select(ScanRun).where(ScanRun.status == "running", ScanRun.kind == "device")
|
||||
)).scalars().all()
|
||||
if any(target in (r.ranges or []) for r in running):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A scan is already running for this device"
|
||||
)
|
||||
|
||||
p = payload or RescanDeviceRequest()
|
||||
deep_scan = _resolve_deep_scan(
|
||||
TriggerScanRequest(
|
||||
http_probe_enabled=p.http_probe_enabled, verify_tls=p.verify_tls
|
||||
)
|
||||
)
|
||||
run = ScanRun(status="running", kind="device", ranges=[target])
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(
|
||||
_background_device_scan, run.id, device_id, deep_scan, p.full_ports, p.ports
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/{run_id}/stop", response_model=dict)
|
||||
async def stop_scan(
|
||||
run_id: str,
|
||||
@@ -933,6 +1044,19 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=ScanRunResponse)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""One run, for a caller waiting on a scan it started (the device rescan)."""
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
return ScanConfig(
|
||||
|
||||
@@ -2,12 +2,21 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def origin_of(url: str) -> str:
|
||||
"""`scheme://host[:port]` of an absolute URL, or "" when it has none."""
|
||||
parts = urlsplit(url)
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return ""
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
def _read_version() -> str:
|
||||
for candidate in [
|
||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||
@@ -88,6 +97,16 @@ class Settings(BaseSettings):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
app_origin = origin_of(self.oidc_redirect_uri)
|
||||
allowed = {value.rstrip("/") for value in self.cors_origins}
|
||||
if app_origin and app_origin not in allowed:
|
||||
logger.warning(
|
||||
"CORS_ORIGINS does not list %s (the origin of OIDC_REDIRECT_URI). "
|
||||
"The CSRF check accepts that origin anyway, but add it to CORS_ORIGINS "
|
||||
"so browser requests are not rejected: CORS_ORIGINS=[\"%s\"]",
|
||||
app_origin,
|
||||
app_origin,
|
||||
)
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
@@ -98,6 +117,14 @@ class Settings(BaseSettings):
|
||||
# ports survive a timeout regardless; raise this on slow/overlay networks.
|
||||
scanner_version_host_timeout: int = 60
|
||||
|
||||
# Per-device deep rescan: total time budget in seconds, checked between port
|
||||
# slices. Unprivileged nmap falls back to a connect scan (-sT), and a host
|
||||
# that drops packets makes every filtered port wait out its RTT — the whole
|
||||
# range against such a host runs ~20-45 min. When the budget is spent the
|
||||
# run keeps what it found and says how much it did not reach. This is NOT an
|
||||
# nmap --host-timeout: that one discards a host's results wholesale.
|
||||
scanner_deep_host_timeout: int = 2700
|
||||
|
||||
# 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] = []
|
||||
|
||||
@@ -14,7 +14,7 @@ from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import origin_of, settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
@@ -153,6 +153,13 @@ def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
# A same-origin deployment behind a reverse proxy needs no CORS, so
|
||||
# CORS_ORIGINS is often left at its localhost default — yet browsers still
|
||||
# send Origin on unsafe methods. The OIDC callback URL is served by this app,
|
||||
# so its origin is the app's own and is always a legitimate CSRF origin.
|
||||
app_origin = origin_of(settings.oidc_redirect_uri)
|
||||
if app_origin:
|
||||
allowed_origins.add(app_origin)
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
|
||||
@@ -358,8 +358,26 @@ def _stamped(item: Any, visible: bool) -> Any:
|
||||
return {**item, "visible": visible}
|
||||
|
||||
|
||||
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins."""
|
||||
# How a service looks is the user's call. A fingerprint only ever guesses it
|
||||
# from a port number and a banner, so a scan that re-finds a known service must
|
||||
# not repaint the icon someone picked by hand — that happened on every "Scan
|
||||
# network", across every canvas drawing the device, at once.
|
||||
_CURATED_SERVICE_FIELDS = ("icon", "category")
|
||||
|
||||
|
||||
def merge_services(
|
||||
base: list[Any] | None,
|
||||
incoming: list[Any] | None,
|
||||
*,
|
||||
discovered: bool = False,
|
||||
) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins.
|
||||
|
||||
``discovered`` marks ``incoming`` as scanner output rather than a user edit:
|
||||
it still adds services and refreshes facts, but leaves an established icon
|
||||
and category alone. Either way a blank incoming value never clears one that
|
||||
is already set — an absent field is silence, not a reset.
|
||||
"""
|
||||
out: list[Any] = [dict(s) if isinstance(s, dict) else s for s in (base or [])]
|
||||
index = {_service_key(s): i for i, s in enumerate(out)}
|
||||
for svc in incoming or []:
|
||||
@@ -369,7 +387,12 @@ def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[A
|
||||
out.append(dict(svc) if isinstance(svc, dict) else svc)
|
||||
index[key] = len(out) - 1
|
||||
elif isinstance(svc, dict) and isinstance(out[pos], dict):
|
||||
out[pos] = {**out[pos], **svc}
|
||||
merged = {**out[pos], **svc}
|
||||
for field_name in _CURATED_SERVICE_FIELDS:
|
||||
established = out[pos].get(field_name)
|
||||
if established and (discovered or not svc.get(field_name)):
|
||||
merged[field_name] = established
|
||||
out[pos] = merged
|
||||
else:
|
||||
out[pos] = svc
|
||||
return out
|
||||
|
||||
+351
-84
@@ -7,6 +7,7 @@ import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -62,8 +63,89 @@ def _valid_port_range(spec: str) -> bool:
|
||||
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."""
|
||||
# Every TCP port. Used by the per-device deep rescan, which trades minutes of
|
||||
# nmap time for a service list that no curated port list can promise.
|
||||
_FULL_PORTS = "1-65535"
|
||||
|
||||
# The deep rescan runs the full range in slices, one nmap call each, unioning
|
||||
# what they find. A slice that runs long costs only its own ports — where a
|
||||
# single 65535-port call that overruns costs everything (see _nmap_scan_single).
|
||||
# It also gives the run somewhere to notice a stop request, and somewhere to
|
||||
# give up when the budget is spent, without abandoning the ports already found.
|
||||
_DEEP_CHUNK_SIZE = 8192
|
||||
|
||||
|
||||
def _parse_port_spec(spec: str) -> list[tuple[int, int]]:
|
||||
"""Parse an nmap ``-p`` spec into sorted, merged ``(start, end)`` ranges.
|
||||
|
||||
Accepts what the user can type in the deep-scan dialog: ``80``,
|
||||
``8000-9000``, or a comma list of both. Returns ``[]`` for anything invalid
|
||||
— the caller turns that into a 422 rather than handing nmap a bad ``-p``.
|
||||
"""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
for token in (t.strip() for t in spec.split(",")):
|
||||
if not token or not _valid_port_range(token):
|
||||
return []
|
||||
parts = [int(p) for p in token.split("-")]
|
||||
ranges.append((parts[0], parts[-1]))
|
||||
if not ranges:
|
||||
return []
|
||||
ranges.sort()
|
||||
merged = [ranges[0]]
|
||||
for start, end in ranges[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if start <= last_end + 1:
|
||||
merged[-1] = (last_start, max(last_end, end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _valid_port_spec(spec: str) -> bool:
|
||||
"""True when ``spec`` is a usable comma list of ports/ranges."""
|
||||
return bool(_parse_port_spec(spec))
|
||||
|
||||
|
||||
def _port_chunks(spec: str, size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice a port spec into nmap ``-p`` specs of at most ``size`` ports each.
|
||||
|
||||
Ranges are packed, not scanned one call per range: ``80,443`` is one chunk,
|
||||
not two, while ``1-65535`` becomes eight. Each chunk is a scan boundary —
|
||||
where a stop request lands and where the time budget is checked.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
budget = size
|
||||
for start, end in _parse_port_spec(spec):
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
take = min(budget, end - cursor + 1)
|
||||
stop = cursor + take - 1
|
||||
current.append(f"{cursor}-{stop}" if stop > cursor else str(cursor))
|
||||
budget -= take
|
||||
cursor = stop + 1
|
||||
if budget == 0:
|
||||
chunks.append(",".join(current))
|
||||
current = []
|
||||
budget = size
|
||||
if current:
|
||||
chunks.append(",".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
def _deep_port_chunks(size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice the whole TCP range into nmap ``-p`` specs of ``size`` ports."""
|
||||
return _port_chunks(_FULL_PORTS, size)
|
||||
|
||||
|
||||
def _build_port_spec(http_ranges: list[str] | None, full: bool = False) -> str:
|
||||
"""Combine the default port list with validated user ranges for nmap -p.
|
||||
|
||||
``full`` overrides everything with the whole TCP range — the deep rescan of
|
||||
a single device, where completeness beats speed.
|
||||
"""
|
||||
if full:
|
||||
return _FULL_PORTS
|
||||
if not http_ranges:
|
||||
return _EXTRA_PORTS
|
||||
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
||||
@@ -238,7 +320,9 @@ async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[
|
||||
return alive
|
||||
|
||||
|
||||
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
||||
def _nmap_scan_single(
|
||||
host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS, bounded: bool = False
|
||||
) -> 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.
|
||||
@@ -262,8 +346,26 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
|
||||
# -sT without root but being explicit avoids edge cases.
|
||||
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||
|
||||
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||
# --- Pass A: port discovery (no -sV) ---
|
||||
# Default timing for the range scan: its ports are authoritative and a
|
||||
# curated port list is fast either way.
|
||||
#
|
||||
# ``bounded`` is the deep rescan's timing, and carries NO --host-timeout on
|
||||
# purpose: nmap answers a host timeout with "Skipping host <ip> due to host
|
||||
# timeout" and discards *every* port it had already found, so a ceiling here
|
||||
# turns a slow scan into one that reports nothing. The caller bounds total
|
||||
# runtime by slicing the range instead.
|
||||
#
|
||||
# What costs the time is a host that drops packets: 8188 of 8192 ports
|
||||
# filtered, each waiting out its probe. Measured against such a host, the
|
||||
# retry pass is the whole cost — 8192 ports took 329s at --max-retries 1
|
||||
# and 164s at 0, finding the same ports. Capping the RTT changed nothing
|
||||
# (329s), so it is not set. Dropping retries risks missing a port that
|
||||
# loses its one probe; that trade belongs to the deep scan alone, and the
|
||||
# curated-port range scan keeps nmap's default retries.
|
||||
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
|
||||
if bounded:
|
||||
discovery_args += " --max-retries 0 --min-rate 2000"
|
||||
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
|
||||
nm_disc = nmap.PortScanner()
|
||||
try:
|
||||
@@ -502,6 +604,110 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
return deleted
|
||||
|
||||
|
||||
async def process_host(
|
||||
db: AsyncSession,
|
||||
host: dict[str, Any],
|
||||
*,
|
||||
hidden_ips: set[str],
|
||||
deep_scan: DeepScanOptions,
|
||||
discovery_source: str = "arp",
|
||||
) -> str:
|
||||
"""Fold one scanned host into ``device_inventory`` and commit.
|
||||
|
||||
Shared by the range scan and the single-device deep rescan, so both apply
|
||||
the same matching, merge and de-duplication rules.
|
||||
|
||||
Returns ``"skipped"`` (hidden by the user), ``"created"`` (a new inventory
|
||||
row) or ``"updated"`` (an existing row refreshed).
|
||||
"""
|
||||
ip = host["ip"]
|
||||
|
||||
# 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 "skipped"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
norm_mac = normalize_mac(host.get("mac"))
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||
|
||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||
# approved so a re-scan refreshes the existing row instead of spawning
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.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.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
# Union, never replace. Since 3.3.0 the row is the only copy of
|
||||
# a device's services — every canvas drawing it reads this list
|
||||
# — so overwriting it with the fingerprint would delete services
|
||||
# the user added by hand, on every canvas at once. What a scan
|
||||
# finds is added; what it no longer sees stays. A service the
|
||||
# user does not want on a canvas is hidden by that node's view,
|
||||
# which is where "I deleted this" belongs.
|
||||
# discovered=: the fingerprint may add services and refresh what it
|
||||
# knows, but never repaints an icon or category the user chose.
|
||||
keep.services = merge_services(keep.services, services, discovered=True)
|
||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
||||
# scan guess; the importer knows the true type.
|
||||
if not (keep.ieee_address or "").startswith("pve-"):
|
||||
keep.suggested_type = suggested_type
|
||||
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
|
||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
||||
# status preserved — an approved device stays approved.
|
||||
# Stamp last_scan on the row so every canvas drawing the device
|
||||
# shows when the scanner last observed it. The row is the one
|
||||
# place that fact belongs; a node only draws it.
|
||||
keep.last_scan = datetime.now(timezone.utc)
|
||||
outcome = "updated"
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
last_scan=datetime.now(timezone.utc),
|
||||
))
|
||||
outcome = "created"
|
||||
|
||||
await db.commit()
|
||||
return outcome
|
||||
|
||||
|
||||
async def run_scan(
|
||||
ranges: list[str],
|
||||
db: AsyncSession,
|
||||
@@ -545,88 +751,17 @@ async def run_scan(
|
||||
|
||||
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# 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)
|
||||
outcome = await process_host(
|
||||
db,
|
||||
host,
|
||||
hidden_ips=hidden_ips,
|
||||
deep_scan=deep_scan,
|
||||
discovery_source=discovery_source,
|
||||
)
|
||||
if outcome == "skipped":
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
norm_mac = normalize_mac(host.get("mac"))
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||
|
||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||
# approved so a re-scan refreshes the existing row instead of spawning
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.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.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
# Union, never replace. Since 3.3.0 the row is the only copy of
|
||||
# a device's services — every canvas drawing it reads this list
|
||||
# — so overwriting it with the fingerprint would delete services
|
||||
# the user added by hand, on every canvas at once. What a scan
|
||||
# finds is added; what it no longer sees stays. A service the
|
||||
# user does not want on a canvas is hidden by that node's view,
|
||||
# which is where "I deleted this" belongs.
|
||||
keep.services = merge_services(keep.services, services)
|
||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
||||
# scan guess; the importer knows the true type.
|
||||
if not (keep.ieee_address or "").startswith("pve-"):
|
||||
keep.suggested_type = suggested_type
|
||||
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
|
||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
||||
# status preserved — an approved device stays approved.
|
||||
# Stamp last_scan on the row so every canvas drawing the device
|
||||
# shows when the scanner last observed it. The row is the one
|
||||
# place that fact belongs; a node only draws it.
|
||||
keep.last_scan = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
last_scan=datetime.now(timezone.utc),
|
||||
))
|
||||
if outcome == "created":
|
||||
devices_found += 1
|
||||
|
||||
await db.commit()
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
# nmap scan per CIDR — results stream in progressively
|
||||
@@ -680,3 +815,135 @@ async def run_scan(
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
async def run_device_scan(
|
||||
device_id: str,
|
||||
db: AsyncSession,
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
"""Deep-rescan one known device and refresh its inventory row.
|
||||
|
||||
No ping sweep and no mDNS: the device is already known, so the IP goes
|
||||
straight to the phase-2 port scan (``-Pn``). ``full_ports`` scans all 65535
|
||||
TCP ports, which is the point of the feature — a device added before the
|
||||
scanner knew a service, or listening on a port no curated list covers.
|
||||
Minutes, not seconds; the run is cancellable like any other.
|
||||
|
||||
``ports`` narrows that to a user-chosen spec (``80,443``, ``1-1024``) and
|
||||
wins over ``full_ports`` — the dialog prefills the full range, so a caller
|
||||
that passes something else means it.
|
||||
"""
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
deep_scan = deep_scan or DeepScanOptions()
|
||||
port_spec = (
|
||||
ports if ports else _build_port_spec(deep_scan.http_ranges, full=full_ports)
|
||||
)
|
||||
|
||||
try:
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None or not device.ip:
|
||||
raise ValueError("Device has no IP to scan")
|
||||
|
||||
host: dict[str, Any] = {
|
||||
"ip": device.ip,
|
||||
"mac": device.mac,
|
||||
"hostname": device.hostname,
|
||||
"os": device.os,
|
||||
"open_ports": [],
|
||||
}
|
||||
# A rescan re-observes a device through the source it already has; it
|
||||
# is not a network discovery. Hardcoding "arp" here would tell a
|
||||
# Proxmox guest, a rack mount or a hand-added host that the network
|
||||
# scanner found it, and it would then answer that source filter.
|
||||
source = device.discovery_source or "arp"
|
||||
|
||||
# The full range goes out in slices so a stop request lands within one
|
||||
# slice instead of at the end, and so a spent budget keeps the ports
|
||||
# found so far. A curated port list is one call, as before.
|
||||
if ports:
|
||||
chunks = _port_chunks(ports)
|
||||
elif full_ports:
|
||||
chunks = _deep_port_chunks()
|
||||
else:
|
||||
chunks = [port_spec]
|
||||
# Retry-free timing pays for itself over thousands of ports on a host
|
||||
# that drops packets; over a handful it only costs accuracy.
|
||||
total_ports = sum(end - start + 1 for start, end in _parse_port_spec(port_spec))
|
||||
bounded = total_ports > _DEEP_CHUNK_SIZE
|
||||
deadline = time.monotonic() + settings.scanner_deep_host_timeout
|
||||
found: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
skipped = 0
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if _is_cancelled(run_id):
|
||||
skipped = len(chunks) - i
|
||||
break
|
||||
if i and time.monotonic() > deadline:
|
||||
skipped = len(chunks) - i
|
||||
logger.warning(
|
||||
"[Deep scan] %s — budget of %ds spent, %d port range(s) not scanned",
|
||||
host["ip"], settings.scanner_deep_host_timeout, skipped,
|
||||
)
|
||||
break
|
||||
# bounded: retry-free timing, for a host that drops packets.
|
||||
scanned = await asyncio.to_thread(
|
||||
_nmap_scan_single, dict(host), chunk, bounded
|
||||
)
|
||||
for port in scanned.get("open_ports") or []:
|
||||
key = (port["protocol"], port["port"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
found.append(port)
|
||||
host["mac"] = host["mac"] or scanned.get("mac")
|
||||
host["os"] = scanned.get("os") or host["os"]
|
||||
|
||||
host["open_ports"] = found
|
||||
# A partial sweep still says what it saw; the row unions it in.
|
||||
partial = (
|
||||
f"Scanned {len(chunks) - skipped}/{len(chunks)} port ranges "
|
||||
f"({len(found)} open) — the rest was not reached"
|
||||
if skipped
|
||||
else None
|
||||
)
|
||||
|
||||
devices_found = 0
|
||||
if not _is_cancelled(run_id):
|
||||
# The row is being rescanned on the user's request, so it is never
|
||||
# "hidden" from itself — the route rejects hidden devices upfront.
|
||||
outcome = await process_host(
|
||||
db, host, hidden_ips=set(), deep_scan=deep_scan, discovery_source=source
|
||||
)
|
||||
if outcome == "created":
|
||||
devices_found = 1
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
||||
run.devices_found = devices_found
|
||||
# Not a failure: a done run carrying an advisory, the way a Proxmox
|
||||
# import reports what it could not see. Never let a partial sweep
|
||||
# read as a complete one.
|
||||
if partial and run.status == "done":
|
||||
run.error = partial
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Device scan failed: %s", exc)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "error"
|
||||
run.error = str(exc)
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
@@ -414,3 +414,154 @@ async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, head
|
||||
assert res.status_code == 409
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [pending_device.id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_requires_auth(client: AsyncClient, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/rescan")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_creates_run_for_that_ip(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["kind"] == "device"
|
||||
# One host, not the configured ranges — the scan targets this device only.
|
||||
assert data["ranges"] == ["192.168.1.100/32"]
|
||||
bg.assert_called_once()
|
||||
assert bg.call_args.args[1] == pending_device.id
|
||||
# Full range unless the caller says otherwise.
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_passes_the_requested_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""The dialog's range reaches the scanner verbatim."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " 80,443,8000-9000 "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] == "80,443,8000-9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_rejects_an_unusable_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""A bad spec is refused here, not handed to nmap."""
|
||||
for bad in ["0", "65536", "100-50", "80,", "http"]:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": bad},
|
||||
)
|
||||
assert res.status_code == 422, f"{bad}: {res.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_treats_a_blank_range_as_the_full_sweep(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] is None
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.post(f"/api/v1/scan/pending/{uuid.uuid4()}/rescan", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_without_ip_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip=None, hostname="zigbee-lamp", status="pending")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
assert "IP" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_hidden_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.77", status="hidden")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_serialized_per_device(client: AsyncClient, headers, pending_device):
|
||||
"""A second rescan while the first still runs is refused, not duplicated."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
second = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
assert "already running" in second.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_allows_a_new_run_once_the_first_finished(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
run_id = first.json()["id"]
|
||||
stopped = await client.post(f"/api/v1/scan/{run_id}/stop", headers=headers)
|
||||
assert stopped.status_code == 200
|
||||
again = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert again.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_returns_status(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
started = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
run_id = started.json()["id"]
|
||||
res = await client.get(f"/api/v1/scan/runs/{run_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["id"] == run_id
|
||||
assert res.json()["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.get(f"/api/v1/scan/runs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
@@ -12,9 +12,14 @@ from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||
session rollback path must execute without a follow-on exception."""
|
||||
async def test_background_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → error and the
|
||||
session rollback path must execute without a follow-on exception.
|
||||
|
||||
"error" and not "failed": one condition, one name — it is what run_scan
|
||||
writes when it catches the failure itself, and the only one Scan History
|
||||
filters and colours.
|
||||
"""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -36,13 +41,13 @@ async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
assert refreshed.status == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||
must NOT overwrite that terminal status with 'failed'."""
|
||||
must NOT overwrite that terminal status with 'error'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -436,3 +441,30 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_device_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""Same word as the network scan — a device run that blows up reads alike."""
|
||||
from app.api.routes.scan import _background_device_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", kind="device", ranges=["10.0.0.5/32"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_device_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_device_scan(run_id, "d1")
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "error"
|
||||
|
||||
@@ -336,6 +336,80 @@ async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: Asy
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_mutation_allows_app_origin_missing_from_cors_origins(
|
||||
client: AsyncClient, oidc_settings
|
||||
):
|
||||
"""Regression for #356: a same-origin reverse-proxy deploy needs no CORS, so
|
||||
CORS_ORIGINS is left at its localhost default while the browser still sends
|
||||
Origin — every POST used to 403."""
|
||||
oidc_settings.cors_origins = ["http://localhost:5173", "http://localhost:3000"]
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "From OIDC", "design_type": "network"},
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
# The fallback widens the allowlist by exactly one origin, nothing more.
|
||||
evil = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Nope", "design_type": "network"},
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert evil.status_code == 403
|
||||
|
||||
|
||||
def test_origin_is_allowed_falls_back_to_the_oidc_redirect_origin(oidc_settings):
|
||||
from app.core.security import origin_is_allowed
|
||||
|
||||
oidc_settings.cors_origins = ["http://localhost:3000"]
|
||||
oidc_settings.oidc_redirect_uri = "https://homelable.example.com/api/v1/auth/oidc/callback"
|
||||
|
||||
assert origin_is_allowed("https://homelable.example.com") is True
|
||||
assert origin_is_allowed("https://homelable.example.com/") is True
|
||||
assert origin_is_allowed("http://localhost:3000") is True
|
||||
assert origin_is_allowed("https://evil.example") is False
|
||||
assert origin_is_allowed(None) is False
|
||||
|
||||
# A redirect URI that is not an absolute URL contributes no origin.
|
||||
oidc_settings.oidc_redirect_uri = "/api/v1/auth/oidc/callback"
|
||||
assert origin_is_allowed("https://homelable.example.com") is False
|
||||
|
||||
|
||||
def test_oidc_settings_warn_when_cors_origins_omits_the_app_origin(caplog):
|
||||
from app.core.config import Settings
|
||||
|
||||
base = {
|
||||
"secret_key": "x" * 32,
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "secret",
|
||||
"oidc_redirect_uri": "https://homelable.example.com/api/v1/auth/oidc/callback",
|
||||
}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["http://localhost:3000"])
|
||||
assert "https://homelable.example.com" in caplog.text
|
||||
assert "CORS_ORIGINS" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["https://homelable.example.com/"])
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
@@ -85,6 +85,49 @@ class TestMergeRules:
|
||||
assert out[1]["port"] == 80
|
||||
|
||||
|
||||
def test_a_scan_never_repaints_an_icon_the_user_picked(self):
|
||||
"""Every "Scan network" used to overwrite a hand-picked brand icon."""
|
||||
base = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "brand:pihole", "category": "network"},
|
||||
]
|
||||
# What fingerprint_ports returns for the same port: its own guess.
|
||||
incoming = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "Globe", "category": "web"},
|
||||
]
|
||||
out = merge_services(base, incoming, discovered=True)
|
||||
assert len(out) == 1
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
assert out[0]["category"] == "network"
|
||||
|
||||
def test_a_scan_still_fills_an_icon_the_service_never_had(self):
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http",
|
||||
"icon": "Globe", "category": "web"}],
|
||||
discovered=True,
|
||||
)
|
||||
assert out[0]["icon"] == "Globe"
|
||||
assert out[0]["category"] == "web"
|
||||
|
||||
def test_a_user_edit_still_changes_the_icon(self):
|
||||
"""The guard is for scanner output only — an edit is an edit."""
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "Globe"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "brand:pihole"}],
|
||||
)
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
|
||||
def test_services_keep_an_established_icon_when_incoming_has_none(self):
|
||||
"""A blank field is silence, not a reset — mirrors merge_properties."""
|
||||
out = merge_services(
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": "brand:pihole"}],
|
||||
[{"port": 80, "protocol": "tcp", "service_name": "http", "icon": None}],
|
||||
)
|
||||
assert out[0]["icon"] == "brand:pihole"
|
||||
|
||||
|
||||
class TestChangedFacts:
|
||||
"""What a save is allowed to write: its edit, not its whole snapshot."""
|
||||
|
||||
|
||||
@@ -375,6 +375,79 @@ def test_nmap_scan_single_non_root_uses_connect_scan():
|
||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||
|
||||
|
||||
def test_nmap_scan_single_discovery_is_unbounded_by_default():
|
||||
"""The range scan's discovery pass keeps its authoritative, untimed run."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.14", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.14", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "--host-timeout" not in args
|
||||
assert "--min-rate" not in args
|
||||
|
||||
|
||||
def test_nmap_scan_single_bounded_never_sets_a_host_timeout():
|
||||
"""A deep slice drops retries — never --host-timeout.
|
||||
|
||||
nmap answers a host timeout with "Skipping host <ip> due to host timeout"
|
||||
and throws away every port it had already found, so a ceiling here turns a
|
||||
slow scan into one that reports nothing. The time on a dropping host goes to
|
||||
the retry pass — measured 2x — so that is what the deep scan gives up.
|
||||
"""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.15", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.15", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host, "1-8192", True)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "-p 1-8192" in args
|
||||
assert "--host-timeout" not in args
|
||||
assert "--max-retries 0" in args
|
||||
|
||||
|
||||
def test_deep_port_chunks_cover_every_port_once():
|
||||
from app.services.scanner import _deep_port_chunks
|
||||
|
||||
chunks = _deep_port_chunks(8192)
|
||||
assert chunks[0] == "1-8192"
|
||||
assert chunks[-1].endswith("-65535")
|
||||
covered = []
|
||||
for c in chunks:
|
||||
lo, hi = (int(x) for x in c.split("-"))
|
||||
covered.extend(range(lo, hi + 1))
|
||||
assert covered == list(range(1, 65536))
|
||||
|
||||
|
||||
def test_port_chunks_pack_ranges_and_honour_the_slice_size():
|
||||
from app.services.scanner import _port_chunks
|
||||
|
||||
# Small ranges share one call instead of one call each.
|
||||
assert _port_chunks("80,443,8000-9000") == ["80,443,8000-9000"]
|
||||
# A range wider than the slice is cut at the slice boundary.
|
||||
assert _port_chunks("1-100", 40) == ["1-40", "41-80", "81-100"]
|
||||
# Overlapping input is merged before slicing, so no port is scanned twice.
|
||||
assert _port_chunks("1-100,50-200", 1000) == ["1-200"]
|
||||
assert _port_chunks("nonsense") == []
|
||||
|
||||
|
||||
def test_parse_port_spec_rejects_what_nmap_could_not_use():
|
||||
from app.services.scanner import _parse_port_spec, _valid_port_spec
|
||||
|
||||
for bad in ["", " ", "0", "65536", "100-50", "80,", "http", "-80"]:
|
||||
assert _parse_port_spec(bad) == [], bad
|
||||
assert _valid_port_spec(bad) is False, bad
|
||||
assert _valid_port_spec("80,443,8000-9000") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1030,3 +1103,336 @@ async def test_run_scan_no_probe_when_disabled(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_device_scan — per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_port_spec_full_covers_every_tcp_port():
|
||||
from app.services.scanner import _build_port_spec
|
||||
|
||||
# full wins over the curated list *and* over user ranges — the deep rescan
|
||||
# is only worth its minutes if it really scans everything.
|
||||
assert _build_port_spec(None, full=True) == "1-65535"
|
||||
assert _build_port_spec(["8000-8100"], full=True) == "1-65535"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_refreshes_services_and_marks_run_done(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[]))
|
||||
await session.commit()
|
||||
|
||||
seen_specs = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
seen_specs.append(port_spec)
|
||||
# A full-range rescan is always bounded, or it never ends.
|
||||
assert bounded is True
|
||||
# Only the slice holding 22 reports it — the union is the caller's job.
|
||||
if port_spec == "1-8192":
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert run.status == "done"
|
||||
assert device.last_scan is not None
|
||||
assert any(s.get("port") == 22 for s in device.services)
|
||||
# A rescan refreshes an existing row; it never spawns a second one.
|
||||
assert device.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_hand_added_services(mem_db):
|
||||
"""Regression: the rescan unions, it does not replace.
|
||||
|
||||
Services the user typed in by hand are the only copy that exists — every
|
||||
canvas drawing the device reads this list.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
hand_added = {"port": 9000, "protocol": "tcp", "service_name": "My App"}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[hand_added]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
ports = {s.get("port") for s in device.services}
|
||||
assert ports == {22, 9000}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_keeps_the_icon_the_user_picked_for_a_service(mem_db):
|
||||
"""Regression: every scan used to repaint hand-picked service icons.
|
||||
|
||||
The fingerprint guesses an icon from the port; the user's choice is the
|
||||
only one that means anything, so a rescan leaves it where it is.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
curated = {
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"service_name": "ssh",
|
||||
"icon": "brand:openssh",
|
||||
"category": "remote",
|
||||
}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[curated]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
matches = [s for s in device.services if s.get("port") == 22]
|
||||
# Merged in place — the name key is case-insensitive, so "SSH" from the
|
||||
# signature does not append a second row next to the user's "ssh".
|
||||
assert len(matches) == 1
|
||||
ssh = matches[0]
|
||||
assert ssh["icon"] == "brand:openssh"
|
||||
assert ssh["category"] == "remote"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_the_device_own_discovery_source(mem_db):
|
||||
"""A rescan re-observes a device; it does not discover it on the network.
|
||||
|
||||
Tagging every rescanned device "arp" told a Proxmox guest or a hand-added
|
||||
host that the network scanner found it, and it then answered that filter.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(
|
||||
id="d1",
|
||||
ip="192.168.1.9",
|
||||
status="approved",
|
||||
services=[],
|
||||
discovery_source="proxmox",
|
||||
discovery_sources=["proxmox"],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 8006, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8006")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
assert device.discovery_sources == ["proxmox"]
|
||||
assert device.discovery_source == "proxmox"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_marks_run_error_when_device_has_no_ip(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip=None, status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert run is not None
|
||||
assert run.status == "error"
|
||||
assert run.error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_skips_nmap_when_cancelled(mem_db):
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
request_cancel(run_id)
|
||||
single = MagicMock()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", new=single), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
single.assert_not_called()
|
||||
assert run is not None
|
||||
assert run.status == "cancelled"
|
||||
# The run cleans up its cancellation flag on the way out.
|
||||
assert run_id not in _cancelled_runs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_unions_ports_across_slices(mem_db):
|
||||
"""Every slice contributes; a slow one costs only its own ports."""
|
||||
from app.services.scanner import _deep_port_chunks, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
lo = int(port_spec.split("-")[0])
|
||||
if lo == 1:
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
elif lo == 8193:
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22, 8096}
|
||||
# A complete sweep carries no advisory.
|
||||
assert run.error is None
|
||||
assert len(_deep_port_chunks()) == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_honours_a_requested_port_range(mem_db):
|
||||
"""A user-chosen range replaces the full sweep — and skips retry-free timing.
|
||||
|
||||
A handful of ports is cheap enough to scan properly; the bounded flags only
|
||||
pay off over thousands of them.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append((port_spec, bounded))
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8000-9000")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == [("8000-9000", False)]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {8096}
|
||||
assert run.status == "done"
|
||||
assert run.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_what_it_found_when_the_budget_runs_out(mem_db):
|
||||
"""A spent budget stops the sweep — it never discards the ports found.
|
||||
|
||||
The earlier --host-timeout did exactly that (nmap skips the host wholesale),
|
||||
which is why a deep scan could come back empty on a slow host.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append(port_spec)
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
# Budget already spent when the first slice returns.
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.services.scanner.settings") as mock_settings, \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
mock_settings.scanner_deep_host_timeout = -1
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == ["1-8192"]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22}
|
||||
assert run.status == "done"
|
||||
# Partial coverage is reported, never passed off as a full sweep.
|
||||
assert run.error is not None
|
||||
assert "1/8" in run.error
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.5",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -109,6 +109,20 @@ export interface DeepScanConfig {
|
||||
|
||||
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
||||
|
||||
/** A row of `scan_runs` — what `/scan/runs` and the device rescan return. */
|
||||
export interface ScanRunSummary {
|
||||
id: string
|
||||
// 'failed' is legacy: runs recorded before the backend settled on 'error'
|
||||
// for the same condition. Still read, never written.
|
||||
status: 'running' | 'done' | 'cancelled' | 'error' | 'failed'
|
||||
kind: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// A device the backend refused to place because an equivalent node already
|
||||
// exists on the target design (same ip/mac/ieee). `existing_node_id` points at
|
||||
// the node already there so the UI can link to it.
|
||||
@@ -158,8 +172,17 @@ export const scanApi = {
|
||||
*/
|
||||
updatePending: (id: string, data: Partial<Omit<InventoryEntry, 'id' | 'status' | 'discovered_at'>>) =>
|
||||
api.patch<InventoryEntry>(`/scan/pending/${id}`, data),
|
||||
/**
|
||||
* Deep-rescan one known device: every TCP port, then re-fingerprint. Answers
|
||||
* "this device predates the scanner knowing that service" (issue #350).
|
||||
* Returns the ScanRun, so the caller polls `run` and can `stop` it.
|
||||
* 409 when the device has no IP, is hidden, or is already being rescanned.
|
||||
*/
|
||||
rescanDevice: (id: string, opts?: { full_ports?: boolean; ports?: string; http_probe_enabled?: boolean; verify_tls?: boolean }) =>
|
||||
api.post<ScanRunSummary>(`/scan/pending/${id}/rescan`, opts ?? {}),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
run: (runId: string) => api.get<ScanRunSummary>(`/scan/runs/${runId}`),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
/** Remove one inventory entry. 409 when a rack still mounts it. */
|
||||
deletePending: (id: string) => api.delete<{ deleted: boolean }>(`/scan/pending/${id}`),
|
||||
|
||||
@@ -146,11 +146,12 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||
{visibleProperties.map((prop) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
const hasValue = Boolean(prop.value.trim())
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
{prop.value.trim() && (
|
||||
<span className={hasValue ? 'truncate max-w-15 shrink-0' : 'truncate min-w-0'} title={prop.key}>{prop.key}</span>
|
||||
{hasValue && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
{/* Properties */}
|
||||
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
const hasValue = Boolean(prop.value.trim())
|
||||
return (
|
||||
<div
|
||||
key={prop.key}
|
||||
@@ -119,8 +120,8 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
{prop.value.trim() && (
|
||||
<span className={hasValue ? 'truncate max-w-15 shrink-0' : 'truncate min-w-0'} title={prop.key}>{prop.key}</span>
|
||||
{hasValue && (
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* A property with no value gives its whole line to the label.
|
||||
*
|
||||
* The label is capped at max-w-15 so a long key cannot crowd out the value
|
||||
* beside it. With no value there is nothing to protect, and keeping the cap
|
||||
* truncated the label against empty space (issue #361).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { Server } from 'lucide-react'
|
||||
import { BaseNode } from '../BaseNode'
|
||||
import type { NodeData, NodeProperty } from '@/types'
|
||||
|
||||
vi.mock('@/stores/canvasStore', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/stores/canvasStore')>('@/stores/canvasStore')
|
||||
return {
|
||||
...actual,
|
||||
useCanvasStore: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ hideIp: false, serviceStatuses: {} }),
|
||||
}
|
||||
})
|
||||
|
||||
function renderNode(properties: NodeProperty[]) {
|
||||
const data: NodeData = { label: 'NAS', type: 'nas', status: 'online', properties }
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<BaseNode
|
||||
{...({ id: 'n1', data, selected: false, icon: Server } as React.ComponentProps<typeof BaseNode>)}
|
||||
/>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function labelOf(container: HTMLElement, key: string) {
|
||||
return [...container.querySelectorAll('span')].find((s) => s.textContent === key)
|
||||
}
|
||||
|
||||
const prop = (key: string, value: string): NodeProperty => ({ key, value, visible: true })
|
||||
|
||||
describe('BaseNode properties', () => {
|
||||
it('drops the label width cap when the value is empty', () => {
|
||||
const { container } = renderNode([prop('A very long property label', '')])
|
||||
const label = labelOf(container, 'A very long property label')
|
||||
expect(label).toBeDefined()
|
||||
expect(label!.className).not.toContain('max-w-15')
|
||||
expect(label!.className).toContain('min-w-0')
|
||||
})
|
||||
|
||||
it('treats a whitespace-only value as empty', () => {
|
||||
const { container } = renderNode([prop('Label', ' ')])
|
||||
const label = labelOf(container, 'Label')
|
||||
expect(label!.className).not.toContain('max-w-15')
|
||||
})
|
||||
|
||||
it('keeps the cap when a value shares the line', () => {
|
||||
const { container } = renderNode([prop('Label', '42')])
|
||||
const label = labelOf(container, 'Label')
|
||||
expect(label!.className).toContain('max-w-15')
|
||||
expect(container.textContent).toContain('· 42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Deep scan — pick what to sweep before starting one.
|
||||
*
|
||||
* The full 65535-port range is the default and the point of the feature
|
||||
* (issue #350), but it costs minutes per host. A user who already knows where
|
||||
* a service lives, or who only wants the low ports back, types a narrower spec
|
||||
* here instead of waiting for the whole range.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Radar } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { countPorts, isValidPortSpec, FULL_PORT_RANGE } from '@/utils/portSpec'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
|
||||
const PRESETS: Array<{ label: string; spec: string }> = [
|
||||
{ label: 'All ports', spec: FULL_PORT_RANGE },
|
||||
{ label: 'Well-known', spec: '1-1024' },
|
||||
{ label: 'Common', spec: '1-10000' },
|
||||
]
|
||||
|
||||
interface DeepScanModalProps {
|
||||
open: boolean
|
||||
target?: string | null
|
||||
onClose: () => void
|
||||
onStart: (ports: string) => void
|
||||
}
|
||||
|
||||
export function DeepScanModal({ open, target, onClose, onStart }: DeepScanModalProps) {
|
||||
// Prefilled with the full range. The caller mounts this only while it is
|
||||
// open, so a previous narrow spec never becomes the next scan's default.
|
||||
const [spec, setSpec] = useState(FULL_PORT_RANGE)
|
||||
|
||||
const trimmed = spec.trim()
|
||||
const valid = isValidPortSpec(trimmed)
|
||||
const total = countPorts(trimmed)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
onStart(trimmed)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md bg-[#161b22] border-[#30363d]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Radar size={15} className="text-[#00d4ff]" />
|
||||
Deep scan{target ? ` — ${target}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="deep-scan-ports" className="text-xs text-muted-foreground">
|
||||
Port range
|
||||
</Label>
|
||||
<Input
|
||||
id="deep-scan-ports"
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={FULL_PORT_RANGE}
|
||||
data-testid="deep-scan-ports"
|
||||
className="h-8 font-mono text-sm bg-[#21262d] border-[#30363d]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
A port, a range, or a comma list — <span className="font-mono">80,443,8000-9000</span>
|
||||
</span>
|
||||
{trimmed !== '' && (
|
||||
<span className={valid ? 'text-muted-foreground shrink-0' : 'text-[#f85149] shrink-0'}>
|
||||
{valid ? `${total.toLocaleString('en-US')} ports` : 'Invalid range'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.spec}
|
||||
type="button"
|
||||
onClick={() => setSpec(preset.spec)}
|
||||
className={`px-2 py-1 text-[10px] border transition-colors cursor-pointer ${modalStyles['modal-radius']} ${
|
||||
trimmed === preset.spec
|
||||
? 'border-[#00d4ff] text-[#00d4ff] bg-[#00d4ff]/10'
|
||||
: 'border-[#30363d] text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Scanning runs in the background — the whole range takes several minutes. Found services are
|
||||
merged into the device; nothing already recorded is removed.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!valid}
|
||||
data-testid="deep-scan-start"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start scan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* badges, then three columns — identity / operations / curation — so a device
|
||||
* reads in one screen instead of a scrolling column of key-value pairs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
HeartPulse,
|
||||
History,
|
||||
Layers,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
Radar,
|
||||
StickyNote,
|
||||
Tags,
|
||||
X,
|
||||
@@ -40,6 +42,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PropertyList } from '@/components/common/PropertyList'
|
||||
import { ServiceModal } from './ServiceModal'
|
||||
import { DeepScanModal } from './DeepScanModal'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -49,6 +52,7 @@ import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { isRackDevice, orderedSources, SOURCE_META } from '@/utils/deviceSources'
|
||||
import { DEVICE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { countPorts } from '@/utils/portSpec'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { NODE_TYPE_LABELS, type CheckMethod, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
@@ -263,18 +267,89 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
const [services, setServices] = useState<ServiceInfo[]>(device?.services ?? [])
|
||||
const [svcModal, setSvcModal] = useState<{ index: number | null; form?: ServiceFormData } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// The id of the deep rescan this modal started, while it is still running.
|
||||
const [rescanRunId, setRescanRunId] = useState<string | null>(null)
|
||||
const [rescanStarting, setRescanStarting] = useState(false)
|
||||
// The port-range dialog, opened by the Deep scan link.
|
||||
const [deepScanOpen, setDeepScanOpen] = useState(false)
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
// Kept in a ref so the poll effect below doesn't restart — and lose its
|
||||
// timer — every time the parent re-renders with a new callback identity.
|
||||
const onSavedRef = useRef(onSaved)
|
||||
onSavedRef.current = onSaved
|
||||
const editingRef = useRef(editing)
|
||||
editingRef.current = editing
|
||||
// Read by the reset effect, which keys on the id alone — the object itself
|
||||
// must not be a dependency.
|
||||
const deviceRef = useRef(device)
|
||||
deviceRef.current = device
|
||||
|
||||
// Reset the form whenever the modal is pointed at another device — the
|
||||
// Reset the form whenever the modal is pointed at *another* device — the
|
||||
// component stays mounted across card clicks in the inventory grid.
|
||||
//
|
||||
// Keyed on the id, never on the object: the parent hands down a fresh
|
||||
// `device` every time the row is refreshed from anywhere — a finishing deep
|
||||
// rescan, a status push, a save — and resetting on those would drop an edit
|
||||
// in progress along with the mode itself. A refreshed row is not a different
|
||||
// device, and only a different device is a reason to throw the form away.
|
||||
const deviceId = device?.id ?? null
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
setForm(toForm(device))
|
||||
setProperties(device.properties ?? [])
|
||||
setServices(device.services ?? [])
|
||||
const d = deviceRef.current
|
||||
if (!d) return
|
||||
setForm(toForm(d))
|
||||
setProperties(d.properties ?? [])
|
||||
setServices(d.services ?? [])
|
||||
setEditing(false)
|
||||
setSvcModal(null)
|
||||
}, [device])
|
||||
setRescanRunId(null)
|
||||
setDeepScanOpen(false)
|
||||
}, [deviceId])
|
||||
|
||||
// Wait on a running deep rescan. A full 65535-port scan takes minutes, so the
|
||||
// run is polled rather than awaited — the user can close the modal, and the
|
||||
// scan keeps going and still shows under Scan History.
|
||||
useEffect(() => {
|
||||
if (!rescanRunId || !deviceId) return
|
||||
let stopped = false
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const { data: run } = await scanApi.run(rescanRunId)
|
||||
if (stopped || run.status === 'running') return
|
||||
setRescanRunId(null)
|
||||
if (run.status === 'error' || run.status === 'failed') {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
return
|
||||
}
|
||||
if (run.status === 'cancelled') {
|
||||
toast.info('Scan stopped')
|
||||
return
|
||||
}
|
||||
const { data: rows } = await scanApi.pending()
|
||||
const fresh = (rows as InventoryEntry[]).find((d) => d.id === deviceId)
|
||||
if (!fresh || stopped) return
|
||||
// Never clobber an edit in progress — the user's unsaved services win.
|
||||
if (!editingRef.current) setServices(fresh.services ?? [])
|
||||
useCanvasStore.getState().applyDeviceFacts(fresh.id, deviceFactsToNodeData(fresh))
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
onSavedRef.current?.(fresh)
|
||||
const n = fresh.services?.length ?? 0
|
||||
const summary = `${n} service${n !== 1 ? 's' : ''}`
|
||||
// A done run can still carry an advisory: the sweep ran out of budget
|
||||
// before every port range. Saying "done" flat would read as complete.
|
||||
if (run.error) {
|
||||
toast.warning(`Scan partial — ${summary}. ${run.error}`)
|
||||
} else {
|
||||
toast.success(`Scan done — ${summary}`)
|
||||
}
|
||||
} catch {
|
||||
// Transient failure: keep polling, the run is still on the server.
|
||||
}
|
||||
}, 3000)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [rescanRunId, deviceId])
|
||||
|
||||
if (!device) return null
|
||||
|
||||
@@ -301,6 +376,31 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
setServices(device.services ?? [])
|
||||
}
|
||||
|
||||
const handleRescan = async (ports: string) => {
|
||||
if (!device.ip || rescanRunId || rescanStarting) return
|
||||
setRescanStarting(true)
|
||||
try {
|
||||
const res = await scanApi.rescanDevice(device.id, { ports })
|
||||
setRescanRunId(res.data.id)
|
||||
const n = countPorts(ports)
|
||||
toast.info(`Deep scan started — ${n.toLocaleString('en-US')} ports, this takes a few minutes`)
|
||||
} catch (err) {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
toast.error(detail ?? 'Could not start the scan')
|
||||
} finally {
|
||||
setRescanStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRescan = async () => {
|
||||
if (!rescanRunId) return
|
||||
try {
|
||||
await scanApi.stop(rescanRunId)
|
||||
} catch {
|
||||
toast.error('Could not stop the scan')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitService = (data: ServiceSubmitData) => {
|
||||
const svc: ServiceInfo = {
|
||||
...(data.port != null ? { port: data.port } : {}),
|
||||
@@ -606,7 +706,36 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
|
||||
{/* Zigbee devices have no IP services — the section would always be empty. */}
|
||||
{!isZigbee && (
|
||||
<Section title={`Services found (${device.services.length})`} icon={Network}>
|
||||
<Section
|
||||
title={`Services found (${device.services.length})`}
|
||||
icon={Network}
|
||||
action={
|
||||
// Deep rescan: the fix for a device added before the
|
||||
// scanner knew its services, or one listening on a port
|
||||
// no curated list covers (issue #350). Needs an IP.
|
||||
device.ip ? (
|
||||
rescanRunId ? (
|
||||
<button
|
||||
onClick={handleStopRescan}
|
||||
data-testid="device-rescan-stop"
|
||||
className="flex items-center gap-1 text-[10px] text-[#f85149] hover:text-[#f85149]/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Loader2 size={10} className="animate-spin" /> Scanning — stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeepScanOpen(true)}
|
||||
disabled={rescanStarting}
|
||||
data-testid="device-rescan"
|
||||
title="Pick a port range and refresh the services"
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Radar size={10} /> Deep scan
|
||||
</button>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{device.services.length === 0 ? (
|
||||
<Empty>No services detected</Empty>
|
||||
) : (
|
||||
@@ -756,6 +885,15 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deepScanOpen && (
|
||||
<DeepScanModal
|
||||
open
|
||||
target={device.ip}
|
||||
onClose={() => setDeepScanOpen(false)}
|
||||
onStart={handleRescan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{svcModal && (
|
||||
<ServiceModal
|
||||
key={`svc-${svcModal.index ?? 'new'}`}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Per-device deep rescan (issue #350) — the detail modal starts a full-port
|
||||
* scan of one device, waits on the run, and folds the fresh services back in.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { InventoryDeviceModal } from '../InventoryDeviceModal'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
const mockRescanDevice = vi.fn()
|
||||
const mockRun = vi.fn()
|
||||
const mockPending = vi.fn()
|
||||
const mockStop = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
updatePending: vi.fn(),
|
||||
rescanDevice: (...a: unknown[]) => mockRescanDevice(...a),
|
||||
run: (...a: unknown[]) => mockRun(...a),
|
||||
pending: (...a: unknown[]) => mockPending(...a),
|
||||
stop: (...a: unknown[]) => mockStop(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||
|
||||
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
ip: '192.168.1.100',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'pve.local',
|
||||
os: 'Linux',
|
||||
services: [],
|
||||
suggested_type: 'server',
|
||||
status: 'pending',
|
||||
discovered_at: '2024-01-15T10:30:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** The link opens the port-range dialog; the scan starts from there. */
|
||||
async function startScan() {
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
await waitFor(() => expect(screen.getByTestId('deep-scan-start')).toBeInTheDocument())
|
||||
// The start resolves a promise that sets state — act() keeps that update
|
||||
// inside the test's control, fake timers or not.
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('deep-scan-start')) })
|
||||
}
|
||||
|
||||
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRescanDevice.mockResolvedValue({ data: { id: 'run-1', status: 'running' } })
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'running', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [] })
|
||||
mockStop.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('InventoryDeviceModal — deep rescan', () => {
|
||||
it('offers the deep scan only when the device has an IP', () => {
|
||||
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
expect(screen.getByTestId('device-rescan')).toBeInTheDocument()
|
||||
|
||||
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2', ip: null })} />)
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('is absent for a Zigbee device — it has no IP services at all', () => {
|
||||
render(
|
||||
<InventoryDeviceModal
|
||||
{...noop}
|
||||
device={makeDevice({ discovery_source: 'zigbee', ieee_address: '0x00124b' })}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('scans every port and swaps to a stop control while it runs', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
expect(mockRescanDevice).toHaveBeenCalledWith('dev-1', { ports: '1-65535' })
|
||||
expect(screen.queryByTestId('device-rescan')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends the range the user typed instead of the whole space', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
const input = await screen.findByTestId('deep-scan-ports')
|
||||
expect(input).toHaveValue('1-65535')
|
||||
|
||||
fireEvent.change(input, { target: { value: '80,443,8000-9000' } })
|
||||
fireEvent.click(screen.getByTestId('deep-scan-start'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockRescanDevice).toHaveBeenCalledWith('dev-1', { ports: '80,443,8000-9000' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to start on a range nmap could not use', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByTestId('device-rescan'))
|
||||
const input = await screen.findByTestId('deep-scan-ports')
|
||||
|
||||
fireEvent.change(input, { target: { value: '99999' } })
|
||||
expect(screen.getByTestId('deep-scan-start')).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('deep-scan-start'))
|
||||
expect(mockRescanDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the run through the same ScanRun the header uses', async () => {
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-rescan-stop'))
|
||||
await waitFor(() => expect(mockStop).toHaveBeenCalledWith('run-1'))
|
||||
})
|
||||
|
||||
it('keeps the button enabled again when the start is refused', async () => {
|
||||
mockRescanDevice.mockRejectedValue({
|
||||
response: { data: { detail: 'A scan is already running for this device' } },
|
||||
})
|
||||
const { toast } = await import('sonner')
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith('A scan is already running for this device')
|
||||
)
|
||||
expect(screen.getByTestId('device-rescan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('folds the freshly found services back into the open modal', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const onSaved = vi.fn()
|
||||
const fresh = makeDevice({
|
||||
services: [{ port: 8096, protocol: 'tcp', service_name: 'Jellyfin' }],
|
||||
})
|
||||
render(<InventoryDeviceModal {...noop} onSaved={onSaved} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'done', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(fresh))
|
||||
// Polling ends with the run — no second request once it is done.
|
||||
const calls = mockRun.mock.calls.length
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(6000) })
|
||||
expect(mockRun.mock.calls.length).toBe(calls)
|
||||
})
|
||||
|
||||
it('keeps an edit in progress when the scan lands', async () => {
|
||||
// The parent patches the row in place on onSaved (`setSelected(saved)`),
|
||||
// handing down a new object for the same device. That must not throw away
|
||||
// a form the user is still filling in — a deep scan runs for minutes.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const onSaved = vi.fn()
|
||||
const device = makeDevice()
|
||||
const fresh = makeDevice({
|
||||
services: [{ port: 8096, protocol: 'tcp', service_name: 'Jellyfin' }],
|
||||
})
|
||||
const { rerender } = render(
|
||||
<InventoryDeviceModal {...noop} onSaved={onSaved} device={device} />
|
||||
)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Media box' } })
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'done', error: null } })
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
// The fresh row still reaches the canvas and the grid — the scan is not
|
||||
// discarded just because a form is open.
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(fresh))
|
||||
rerender(<InventoryDeviceModal {...noop} onSaved={onSaved} device={fresh} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Media box')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still resets the form when pointed at another device', async () => {
|
||||
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Media box' } })
|
||||
|
||||
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2' })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
expect(screen.queryByDisplayValue('Media box')).toBeNull()
|
||||
})
|
||||
|
||||
it('says partial when the sweep ran out of budget', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const { toast } = await import('sonner')
|
||||
const fresh = makeDevice({ services: [{ port: 22, protocol: 'tcp', service_name: 'SSH' }] })
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
// A done run carrying an advisory — it scanned part of the range only.
|
||||
mockRun.mockResolvedValue({
|
||||
data: { id: 'run-1', status: 'done', error: 'Scanned 3/8 port ranges (1 open) — the rest was not reached' },
|
||||
})
|
||||
mockPending.mockResolvedValue({ data: [fresh] })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Scan partial — 1 service')
|
||||
)
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed run instead of silently ending', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const { toast } = await import('sonner')
|
||||
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
|
||||
await startScan()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan-stop')).toBeInTheDocument())
|
||||
|
||||
mockRun.mockResolvedValue({ data: { id: 'run-1', status: 'error', error: 'nmap missing' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3100) })
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Scan failed: nmap missing'))
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.getByTestId('device-rescan')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsePortSpec, isValidPortSpec, countPorts, FULL_PORT_RANGE } from '../portSpec'
|
||||
|
||||
describe('parsePortSpec', () => {
|
||||
it('reads a single port, a range and a comma list', () => {
|
||||
expect(parsePortSpec('80')).toEqual([[80, 80]])
|
||||
expect(parsePortSpec('1-1024')).toEqual([[1, 1024]])
|
||||
expect(parsePortSpec('443,80')).toEqual([[80, 80], [443, 443]])
|
||||
})
|
||||
|
||||
it('merges overlapping and adjacent ranges', () => {
|
||||
expect(parsePortSpec('1-100,50-200')).toEqual([[1, 200]])
|
||||
expect(parsePortSpec('1-100,101-200')).toEqual([[1, 200]])
|
||||
expect(parsePortSpec('1-100,300-400')).toEqual([[1, 100], [300, 400]])
|
||||
})
|
||||
|
||||
it('tolerates whitespace around tokens', () => {
|
||||
expect(parsePortSpec(' 80 , 443 ')).toEqual([[80, 80], [443, 443]])
|
||||
})
|
||||
|
||||
it('rejects what nmap could not use', () => {
|
||||
for (const bad of ['', ' ', '0', '65536', '100-50', '80,', 'http', '80-', '-80', '1-2-3']) {
|
||||
expect(parsePortSpec(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidPortSpec / countPorts', () => {
|
||||
it('counts merged ranges once', () => {
|
||||
expect(countPorts('1-100,50-200')).toBe(200)
|
||||
expect(countPorts('80,443')).toBe(2)
|
||||
expect(countPorts(FULL_PORT_RANGE)).toBe(65535)
|
||||
})
|
||||
|
||||
it('counts an invalid spec as nothing', () => {
|
||||
expect(isValidPortSpec('nope')).toBe(false)
|
||||
expect(countPorts('nope')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* nmap-style port specs — `80`, `1-1024`, or a comma list of both.
|
||||
*
|
||||
* Mirrors `_parse_port_spec` in `backend/app/services/scanner.py`: the deep-scan
|
||||
* dialog validates here so a typo is caught before a request, and the backend
|
||||
* validates again because it is the one handing the spec to nmap.
|
||||
*/
|
||||
|
||||
export const FULL_PORT_RANGE = '1-65535'
|
||||
|
||||
const TOKEN_RE = /^\d{1,5}(-\d{1,5})?$/
|
||||
|
||||
/**
|
||||
* Sorted, merged `[start, end]` ranges, or `null` when the spec is unusable.
|
||||
* An empty spec is unusable too — a scan of nothing is never what was meant.
|
||||
*/
|
||||
export function parsePortSpec(spec: string): Array<[number, number]> | null {
|
||||
const tokens = spec.split(',').map((t) => t.trim())
|
||||
if (tokens.length === 0 || tokens.some((t) => t === '')) return null
|
||||
|
||||
const ranges: Array<[number, number]> = []
|
||||
for (const token of tokens) {
|
||||
if (!TOKEN_RE.test(token)) return null
|
||||
const parts = token.split('-').map(Number)
|
||||
const start = parts[0]
|
||||
const end = parts[parts.length - 1]
|
||||
if (start < 1 || end > 65535 || start > end) return null
|
||||
ranges.push([start, end])
|
||||
}
|
||||
|
||||
ranges.sort((a, b) => a[0] - b[0])
|
||||
const merged: Array<[number, number]> = [ranges[0]]
|
||||
for (const [start, end] of ranges.slice(1)) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (start <= last[1] + 1) last[1] = Math.max(last[1], end)
|
||||
else merged.push([start, end])
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function isValidPortSpec(spec: string): boolean {
|
||||
return parsePortSpec(spec) !== null
|
||||
}
|
||||
|
||||
/** How many ports a spec covers — 0 when it is invalid. */
|
||||
export function countPorts(spec: string): number {
|
||||
const ranges = parsePortSpec(spec)
|
||||
if (!ranges) return 0
|
||||
return ranges.reduce((sum, [start, end]) => sum + (end - start + 1), 0)
|
||||
}
|
||||
Reference in New Issue
Block a user