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