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 — 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user