feat: add localhost web UI for scans, blocklist, and firewall management
This commit is contained in:
1 parent
a9dc560f80
commit
4ef6892916
19 files changed
+1934
-117
No files matched your search
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Background scan runner for the web UI.
|
||||
|
||||
Runs scans in a daemon thread so HTTP requests never block, keeps an
|
||||
in-memory job store, and serializes scans with a busy lock (WHOIS and
|
||||
whitelist refresh must not run concurrently).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from .copyparty import CopypartyAnalyzer
|
||||
from .npm import NpmAnalyzer
|
||||
from .scan import ScanParams, ScanResult, run_scan
|
||||
|
||||
ScanKind = Literal["copyparty", "npm", "consolidated"]
|
||||
|
||||
#: serializes scans — only one runs at a time
|
||||
_scan_lock = threading.Lock()
|
||||
|
||||
#: in-memory job store (job_id -> ScanJob)
|
||||
_jobs: dict[str, "ScanJob"] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanJob:
|
||||
"""One background scan job."""
|
||||
|
||||
job_id: str
|
||||
kind: ScanKind
|
||||
params: ScanParams
|
||||
state: str = "queued" # queued | running | done | error
|
||||
started: str | None = None
|
||||
finished: str | None = None
|
||||
result: ScanResult | None = None
|
||||
error: str | None = None
|
||||
busy: bool = False
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = self.result
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"kind": self.kind,
|
||||
"state": self.state,
|
||||
"started": self.started,
|
||||
"finished": self.finished,
|
||||
"error": self.error,
|
||||
"busy": self.busy,
|
||||
"details": self.details,
|
||||
"result": None if result is None else {
|
||||
"ok": result.ok,
|
||||
"cp_exists": result.cp_exists,
|
||||
"npm_exists": result.npm_exists,
|
||||
"cp_candidates": result.cp_candidates,
|
||||
"npm_candidates": result.npm_candidates,
|
||||
"blocked_ips": result.blocked_ips,
|
||||
"blocked_targets": result.blocked_targets,
|
||||
"blocklist_v4_path": result.blocklist_v4_path,
|
||||
"blocklist_v6_path": result.blocklist_v6_path,
|
||||
"firewall_connected": result.firewall_connected,
|
||||
"firewall_changes": result.firewall_changes,
|
||||
"errors": result.errors,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
|
||||
"""Start a scan in the background and return its job dict.
|
||||
|
||||
If another scan is already running, returns a dict with an ``error`` key.
|
||||
"""
|
||||
if not _scan_lock.acquire(blocking=False):
|
||||
return {"error": "a scan is already running; try again when it finishes"}
|
||||
|
||||
job = ScanJob(job_id=uuid.uuid4().hex[:12], kind=kind,
|
||||
params=params, busy=True)
|
||||
with _jobs_lock:
|
||||
_jobs[job.job_id] = job
|
||||
|
||||
def _run() -> None:
|
||||
job.started = _now()
|
||||
job.state = "running"
|
||||
try:
|
||||
if kind == "consolidated":
|
||||
job.result = run_scan(params)
|
||||
elif kind == "copyparty":
|
||||
info = CopypartyAnalyzer(
|
||||
log_path=params.copyparty_log_path,
|
||||
report_dir=params.copyparty_report_dir,
|
||||
top=params.top,
|
||||
quiet=params.quiet,
|
||||
min_denied=params.min_denied,
|
||||
).run()
|
||||
job.result = ScanResult(ok=True)
|
||||
job.details = {"report": info.get("report"),
|
||||
"data_dir": info.get("data_dir")}
|
||||
elif kind == "npm":
|
||||
info = NpmAnalyzer(
|
||||
log_path=params.npm_log_path,
|
||||
report_dir=params.npm_report_dir,
|
||||
top=params.top,
|
||||
quiet=params.quiet,
|
||||
min_errors=params.min_errors,
|
||||
error_ratio=params.error_ratio,
|
||||
).run()
|
||||
job.result = ScanResult(ok=True)
|
||||
job.details = {"report": info.get("report"),
|
||||
"data_dir": info.get("data_dir")}
|
||||
else: # pragma: no cover - guarded by type hint
|
||||
raise ValueError(f"unknown scan kind: {kind}")
|
||||
job.state = "done"
|
||||
except Exception as e:
|
||||
job.error = str(e)
|
||||
job.state = "error"
|
||||
finally:
|
||||
job.finished = _now()
|
||||
job.busy = False
|
||||
_scan_lock.release()
|
||||
|
||||
threading.Thread(target=_run, daemon=True,
|
||||
name=f"scan-{job.job_id}").start()
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
def get_scan(job_id: str) -> dict[str, Any] | None:
|
||||
"""Return a single job as a dict, or None if unknown."""
|
||||
with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
return job.to_dict() if job else None
|
||||
|
||||
|
||||
def latest_scans(limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Return the most recently started jobs, newest first."""
|
||||
with _jobs_lock:
|
||||
jobs = sorted(_jobs.values(),
|
||||
key=lambda j: j.started or "", reverse=True)
|
||||
return [j.to_dict() for j in jobs[:limit]]
|
||||
|
||||
|
||||
def is_scan_running() -> bool:
|
||||
"""True if any job currently holds the scan lock."""
|
||||
return _scan_lock.locked()
|
||||
Reference in new issue
Block a user