feat: expose deep-scan settings via API and persisted config

Adds scanner_http_ranges / scanner_http_probe_enabled / scanner_http_verify_tls
to Settings (persisted in scan_config.json, Options page defaults). /scan/trigger
accepts an optional body to override these per-scan; /scan/config GET/POST read
and persist the defaults. Port ranges validated at the API boundary.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-24 10:04:31 +02:00
parent 9d1ebd6c6e
commit b6423c0115
4 changed files with 211 additions and 8 deletions
+6
View File
@@ -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
+77 -7
View File
@@ -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
@@ -427,17 +479,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
+15
View File
@@ -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,
})) }))
+113 -1
View File
@@ -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()
@@ -1122,3 +1123,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}