feat: wire deep-scan port ranges and HTTP probe into scanner

run_scan() accepts DeepScanOptions: user port ranges are validated and appended
to the nmap -p list; when http_probe_enabled, open ports are probed for HTTP
signals before fingerprinting. Defaults (no options) reproduce the standard
scan exactly — probe is never called, port list unchanged.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-23 14:04:15 +02:00
parent 44e0029f2b
commit 9d1ebd6c6e
3 changed files with 197 additions and 13 deletions
+63 -11
View File
@@ -7,6 +7,7 @@ import re
import socket
import subprocess
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@@ -15,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports
logger = logging.getLogger(__name__)
@@ -34,6 +36,37 @@ _EXTRA_PORTS = (
"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 = [
"_http._tcp.local.",
"_shelly._tcp.local.",
@@ -195,7 +228,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
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.
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
if is_root:
# 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:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# 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)
nm = nmap.PortScanner()
@@ -252,7 +285,9 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
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.
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 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)
results = []
@@ -279,7 +314,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
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.
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:
logger.error("Phase 1 ping sweep failed: %s", 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]]:
@@ -375,10 +410,18 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
]
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
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."""
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
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try:
@@ -427,8 +470,17 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
logger.debug("Skipping %s — hidden by user", ip)
return
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
open_ports = host["open_ports"]
# Deep-scan HTTP probe: enrich open ports with title/header signals so
# fingerprint can confirm services on custom ports. No-op when disabled
# or when the host has no open ports (e.g. mDNS-only discovery).
if deep_scan.http_probe_enabled and open_ports:
open_ports = await probe_open_ports(
ip, open_ports, verify_tls=deep_scan.verify_tls
)
services = fingerprint_ports(open_ports)
suggested_type = suggest_node_type(open_ports, host.get("mac"))
existing_result = await db.execute(
select(PendingDevice).where(
@@ -463,7 +515,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
for cidr in ranges:
if _is_cancelled(run_id):
break
hosts = await _nmap_scan(cidr)
hosts = await _nmap_scan(cidr, port_spec)
for host in hosts:
if _is_cancelled(run_id):
break
+1 -1
View File
@@ -518,7 +518,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
call_count = 0
def nmap_side_effect(target: str):
def nmap_side_effect(target: str, port_spec: str | None = None):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
+133 -1
View File
@@ -359,7 +359,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception():
call_count = 0
def _flaky_scan(host_dict):
def _flaky_scan(host_dict, port_spec=None):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
@@ -533,3 +533,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
run = await session.get(ScanRun, run_id)
assert run is not None
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()