diff --git a/.env.example b/.env.example index 52fa748..1f2b9b9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 390c86a..4e1214a 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f599f74..b9f4016 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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, })) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 56ea3ed..7032772 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -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}