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:
@@ -14,6 +14,12 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
||||
# Scanner — JSON array of CIDR ranges to scan
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
|
||||
# custom ports. Defaults below are overridable per-scan from the scan dialog.
|
||||
SCANNER_HTTP_RANGES=[]
|
||||
SCANNER_HTTP_PROBE_ENABLED=false
|
||||
SCANNER_HTTP_VERIFY_TLS=false
|
||||
|
||||
# Status checker interval in seconds
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.zigbee_service import build_zigbee_properties
|
||||
|
||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||
@@ -52,8 +52,20 @@ class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
|
||||
|
||||
def _check_port_ranges(v: list[str]) -> list[str]:
|
||||
for r in v:
|
||||
if not _valid_port_range(r.strip()):
|
||||
raise ValueError(f"Invalid port range: {r!r}")
|
||||
return v
|
||||
|
||||
|
||||
class ScanConfig(BaseModel):
|
||||
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
|
||||
|
||||
ranges: list[str]
|
||||
http_ranges: list[str] = []
|
||||
http_probe_enabled: bool = False
|
||||
verify_tls: bool = False
|
||||
|
||||
@field_validator("ranges")
|
||||
@classmethod
|
||||
@@ -65,15 +77,35 @@ class ScanConfig(BaseModel):
|
||||
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
||||
return v
|
||||
|
||||
@field_validator("http_ranges")
|
||||
@classmethod
|
||||
def validate_http_ranges(cls, v: list[str]) -> list[str]:
|
||||
return _check_port_ranges(v)
|
||||
|
||||
|
||||
class TriggerScanRequest(BaseModel):
|
||||
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
|
||||
|
||||
http_ranges: list[str] | None = None
|
||||
http_probe_enabled: bool | None = None
|
||||
verify_tls: bool | None = None
|
||||
|
||||
@field_validator("http_ranges")
|
||||
@classmethod
|
||||
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
|
||||
return None if v is None else _check_port_ranges(v)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
async def _background_scan(
|
||||
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await run_scan(ranges, db, run_id)
|
||||
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
|
||||
except Exception:
|
||||
logger.exception("Scan run %s failed unexpectedly", run_id)
|
||||
await db.rollback()
|
||||
@@ -83,18 +115,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
|
||||
"""Merge per-scan overrides over persisted settings defaults."""
|
||||
p = payload or TriggerScanRequest()
|
||||
return DeepScanOptions(
|
||||
http_ranges=(
|
||||
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
|
||||
),
|
||||
http_probe_enabled=(
|
||||
p.http_probe_enabled
|
||||
if p.http_probe_enabled is not None
|
||||
else settings.scanner_http_probe_enabled
|
||||
),
|
||||
verify_tls=(
|
||||
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=ScanRunResponse)
|
||||
async def trigger_scan(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: TriggerScanRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
ranges = settings.scanner_ranges
|
||||
deep_scan = _resolve_deep_scan(payload)
|
||||
run = ScanRun(status="running", ranges=ranges)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_scan, run.id, ranges)
|
||||
background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
|
||||
return run
|
||||
|
||||
|
||||
@@ -427,17 +479,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
return ScanConfig(ranges=settings.scanner_ranges)
|
||||
return ScanConfig(
|
||||
ranges=settings.scanner_ranges,
|
||||
http_ranges=settings.scanner_http_ranges,
|
||||
http_probe_enabled=settings.scanner_http_probe_enabled,
|
||||
verify_tls=settings.scanner_http_verify_tls,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ScanConfig)
|
||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||
previous = settings.scanner_ranges
|
||||
previous = (
|
||||
settings.scanner_ranges,
|
||||
settings.scanner_http_ranges,
|
||||
settings.scanner_http_probe_enabled,
|
||||
settings.scanner_http_verify_tls,
|
||||
)
|
||||
settings.scanner_ranges = payload.ranges
|
||||
settings.scanner_http_ranges = payload.http_ranges
|
||||
settings.scanner_http_probe_enabled = payload.http_probe_enabled
|
||||
settings.scanner_http_verify_tls = payload.verify_tls
|
||||
try:
|
||||
settings.save_overrides()
|
||||
return payload
|
||||
except Exception as exc:
|
||||
settings.scanner_ranges = previous
|
||||
(
|
||||
settings.scanner_ranges,
|
||||
settings.scanner_http_ranges,
|
||||
settings.scanner_http_probe_enabled,
|
||||
settings.scanner_http_verify_tls,
|
||||
) = previous
|
||||
logger.error("Failed to save scan config: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||
|
||||
@@ -48,6 +48,12 @@ class Settings(BaseSettings):
|
||||
# Scanner
|
||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||
|
||||
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
||||
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||
scanner_http_ranges: list[str] = []
|
||||
scanner_http_probe_enabled: bool = False
|
||||
scanner_http_verify_tls: bool = False
|
||||
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
@@ -85,6 +91,12 @@ class Settings(BaseSettings):
|
||||
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||
if "service_check_interval" in data:
|
||||
self.service_check_interval = int(data["service_check_interval"])
|
||||
if "scanner_http_ranges" in data:
|
||||
self.scanner_http_ranges = list(data["scanner_http_ranges"])
|
||||
if "scanner_http_probe_enabled" in data:
|
||||
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
||||
if "scanner_http_verify_tls" in data:
|
||||
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -96,6 +108,9 @@ class Settings(BaseSettings):
|
||||
"status_checker_interval": self.status_checker_interval,
|
||||
"service_check_enabled": self.service_check_enabled,
|
||||
"service_check_interval": self.service_check_interval,
|
||||
"scanner_http_ranges": self.scanner_http_ranges,
|
||||
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
||||
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
+113
-1
@@ -122,7 +122,8 @@ async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
from app.services.scanner import DeepScanOptions
|
||||
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
|
||||
mock_run_scan.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -1122,3 +1123,114 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert links == [] # consumed
|
||||
|
||||
|
||||
# --- Deep scan: trigger overrides + config persistence ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_deep_scan_falls_back_to_settings():
|
||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_http_ranges = ["7000-7100"]
|
||||
mock_settings.scanner_http_probe_enabled = True
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
# Empty payload → all values come from settings defaults
|
||||
ds = _resolve_deep_scan(TriggerScanRequest())
|
||||
assert ds.http_ranges == ["7000-7100"]
|
||||
assert ds.http_probe_enabled is True
|
||||
assert ds.verify_tls is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_deep_scan_override_wins():
|
||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
ds = _resolve_deep_scan(
|
||||
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
|
||||
)
|
||||
assert ds.http_ranges == ["9000"]
|
||||
assert ds.http_probe_enabled is True
|
||||
assert ds.verify_tls is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
|
||||
captured = {}
|
||||
|
||||
async def fake_bg(run_id, ranges, deep_scan):
|
||||
captured["deep_scan"] = deep_scan
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan._background_scan", new=fake_bg),
|
||||
patch("app.api.routes.scan.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
res = await client.post(
|
||||
"/api/v1/scan/trigger",
|
||||
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert captured["deep_scan"].http_probe_enabled is True
|
||||
assert captured["deep_scan"].http_ranges == ["8000-8100"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
res = await client.post(
|
||||
"/api/v1/scan/trigger",
|
||||
json={"http_ranges": ["70000-80000"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = ["8000-8100"]
|
||||
mock_settings.scanner_http_probe_enabled = True
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
res = await client.get("/api/v1/scan/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["http_ranges"] == ["8000-8100"]
|
||||
assert data["http_probe_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
|
||||
saved = {}
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
mock_settings.save_overrides = lambda: saved.update(
|
||||
http_ranges=mock_settings.scanner_http_ranges,
|
||||
probe=mock_settings.scanner_http_probe_enabled,
|
||||
)
|
||||
res = await client.post(
|
||||
"/api/v1/scan/config",
|
||||
json={
|
||||
"ranges": ["192.168.1.0/24"],
|
||||
"http_ranges": ["9000-9100"],
|
||||
"http_probe_enabled": True,
|
||||
"verify_tls": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
||||
|
||||
Reference in New Issue
Block a user