diff --git a/README.MD b/README.MD index 3219c98..a9d9dff 100644 --- a/README.MD +++ b/README.MD @@ -56,6 +56,27 @@ L'URL viene rilevato dalla prima stringa che contiene un link HTTP/HTTPS nel log La dashboard aggiorna automaticamente lo stato dei tunnel e mostra l'ultimo log per aiutare il debug quando l'URL non è disponibile. +## Politica porte + +La parte critica di sicurezza del progetto è la politica che decide quali porte possono essere esposte tramite tunnel. Ci sono due layer di configurazione, entrambi opzionali: + +- `TUNNEL_WHITELIST` (es. `22,8000,9000`) o `TUNNEL_WHITELIST_FILE` (JSON con una lista o un oggetto `{ "whitelist": [80, 8888] }`) per **consentire solo** determinati numeri di porta. +- `TUNNEL_BLOCKLIST` o `TUNNEL_BLOCKLIST_FILE` (stesso formato del whitelist) per **bloccare** porte critiche anche se l'utente le seleziona. + +Quando non viene esplicitata nessuna whitelist, il servizio blocca per default le porte sensibili `22, 23, 2375, 2376, 2377, 6443 e 9000`. Le richieste `/api/tunnels` restituiscono un errore 403 con il motivo della violazione se si prova ad aprire un tunnel vietato. + +La tabella "Porte in ascolto" ora mostra un badge **Policy** che indica se la porta è consentita o se è stata bloccata dal policy engine. + +## Persistenza e audit + +Lo stato dei servizi e dei tunnel viene ora salvato in un database SQLite (`data/dash.db` nell'ambiente predefinito) così da non perdere il tracciamento dopo il riavvio del servizio. Puoi cambiare la posizione con la variabile `TUNNEL_DB_PATH`. + +- **`services`**: panoramica delle porte rilevate, con timestamp, utente, comando e attenzione alle policy. +- **`tunnels`**: stato corrente (o ultimo) dei tunnel avviati, con PID, URL e log dell'ultima uscita. +- **`tunnel_events`**: audit log con timestamp e messaggi (avvio, stop, errori). + +La dashboard espone un nuovo pannello "Audit tunnel" e le API `GET /api/history/events` e `GET /api/history/services` per leggere i log recenti direttamente dal browser o da script esterni. Ciò semplifica la ricerca di chi ha avviato un tunnel, quando è stato chiuso e con quale comando. + ## Debug Nella sezione dei tunnel puoi aprire il pannello Log per vedere le ultime righe prodotte dal processo `gradio_tunneling`. Questo aiuta a capire se il tunnel non parte o se l'URL non viene stampato. diff --git a/app/main.py b/app/main.py index 256b99d..04f5c9f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,6 @@ from __future__ import annotations import dataclasses -import subprocess from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse @@ -9,12 +8,16 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel +from app.policy import TunnelPolicy from app.services import PortScanner +from app.storage import Storage from app.tunnel_manager import TunnelManager app = FastAPI(title="Gradio Tunnel Dashboard") -manager = TunnelManager() -scanner = PortScanner() +policy = TunnelPolicy.load() +storage = Storage.from_env() +scanner = PortScanner(policy=policy, storage=storage) +manager = TunnelManager(storage=storage) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") @@ -52,6 +55,16 @@ async def list_tunnels() -> list[dict[str, str | int | None]]: ] +@app.get("/api/history/events") +async def history_events() -> list[dict[str, object | None]]: + return storage.recent_events() + + +@app.get("/api/history/services") +async def history_services() -> list[dict[str, object | None]]: + return storage.recent_services() + + @app.get("/api/tunnels/{port}/logs") async def tunnel_logs(port: int) -> dict[str, list[str]]: logs = manager.get_logs(port) @@ -64,6 +77,9 @@ async def tunnel_logs(port: int) -> dict[str, list[str]]: async def start_tunnel(payload: TunnelRequest) -> dict[str, str | int | None]: if payload.port <= 0 or payload.port > 65535: raise HTTPException(status_code=400, detail="Invalid port.") + allowed, reason = policy.is_port_allowed(payload.port) + if not allowed: + raise HTTPException(status_code=403, detail=reason or "Porta non autorizzata.") info = manager.start_tunnel(payload.port) return { "port": info.port, @@ -87,3 +103,4 @@ async def stop_tunnel(port: int) -> dict[str, bool]: @app.on_event("shutdown") def shutdown_event() -> None: manager.shutdown() + storage.close() diff --git a/app/policy.py b/app/policy.py new file mode 100644 index 0000000..cf94bb7 --- /dev/null +++ b/app/policy.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Iterable, Sequence + +DEFAULT_BLOCKED_PORTS = frozenset({22, 23, 2375, 2376, 2377, 6443, 2377, 9000}) + + +class TunnelPolicy: + def __init__(self, whitelist: frozenset[int] | None, blocklist: frozenset[int]) -> None: + self.whitelist = whitelist + self.blocklist = blocklist + + @classmethod + def load(cls) -> "TunnelPolicy": + whitelist_inputs: list[int] = [] + blocklist_inputs: list[int] = [] + + env_whitelist = os.environ.get("TUNNEL_WHITELIST") + if env_whitelist: + whitelist_inputs.extend(_parse_port_sequence(env_whitelist)) + + env_blocklist = os.environ.get("TUNNEL_BLOCKLIST") + if env_blocklist: + blocklist_inputs.extend(_parse_port_sequence(env_blocklist)) + + whitelist_file = os.environ.get("TUNNEL_WHITELIST_FILE") + if whitelist_file: + whitelist_inputs.extend(_load_ports_from_file(whitelist_file, "whitelist")) + + blocklist_file = os.environ.get("TUNNEL_BLOCKLIST_FILE") + if blocklist_file: + blocklist_inputs.extend(_load_ports_from_file(blocklist_file, "blocklist")) + + whitelist = frozenset(whitelist_inputs) if whitelist_inputs else None + blocklist = frozenset(blocklist_inputs) or DEFAULT_BLOCKED_PORTS + return cls(whitelist=whitelist, blocklist=blocklist) + + def is_port_allowed(self, port: int) -> tuple[bool, str | None]: + if port in self.blocklist: + return False, f"Porta {port} in lista sensibile/protetta" + if self.whitelist is not None and port not in self.whitelist: + return False, f"Porta {port} non presente nella whitelist" + return True, None + + +def _parse_port_sequence(value: str) -> Sequence[int]: + cleaned = [item.strip() for item in value.replace(";", ",").split(",") if item.strip()] + ports: list[int] = [] + for bit in cleaned: + try: + ports.append(int(bit, 10)) + except ValueError: + continue + return ports + + +def _load_ports_from_file(path: str, key: str) -> Sequence[int]: + try: + raw = Path(path).read_text(encoding="utf-8") + except OSError: + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return [] + if isinstance(parsed, dict): + target = parsed.get(key) + return _normalize_numeric_sequence(target) + return _normalize_numeric_sequence(parsed) + + +def _normalize_numeric_sequence(value: Iterable | None) -> Sequence[int]: + if not value: + return [] + ports: list[int] = [] + for entry in value: + try: + ports.append(int(entry)) + except (TypeError, ValueError): + continue + return ports diff --git a/app/services.py b/app/services.py index 5392c6f..1e58d5e 100644 --- a/app/services.py +++ b/app/services.py @@ -4,11 +4,14 @@ import shlex import socket import subprocess import time -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Iterable, List, Optional import psutil +from app.policy import TunnelPolicy +from app.storage import Storage + @dataclass class ServiceRecord: @@ -20,13 +23,22 @@ class ServiceRecord: command: Optional[str] container: Optional[str] local_address: str + allowed: bool = True + policy_reason: Optional[str] = None class PortScanner: - def __init__(self, cache_ttl: float = 2.0) -> None: + def __init__( + self, + cache_ttl: float = 2.0, + policy: TunnelPolicy | None = None, + storage: Storage | None = None, + ) -> None: self._cache_ttl = cache_ttl self._last_scan = 0.0 self._cache: List[ServiceRecord] = [] + self._policy = policy + self._storage = storage def scan(self) -> List[ServiceRecord]: now = time.monotonic() @@ -68,6 +80,10 @@ class PortScanner: process_command = _format_commandline(raw_command) local_address = f"{conn.laddr.ip}:{conn.laddr.port}" container = _container_name_for_pid(pid, container_names) if pid else None + allowed = True + reason: Optional[str] = None + if self._policy: + allowed, reason = self._policy.is_port_allowed(port) results.append( ServiceRecord( @@ -79,11 +95,23 @@ class PortScanner: command=process_command, container=container, local_address=local_address, + allowed=allowed, + policy_reason=reason, ) ) + self._persist_services(results) return results + def _persist_services(self, records: List[ServiceRecord]) -> None: + if not self._storage: + return + payload = [asdict(record) for record in records] + try: + self._storage.record_services(payload) + except Exception: + pass + def _protocol_for_conn(sock_type: int) -> str: if sock_type == socket.SOCK_STREAM: diff --git a/app/static/app.js b/app/static/app.js index fb8ac2a..c699e6c 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -2,12 +2,12 @@ const portsTable = document.getElementById("ports-table"); const tunnelsTable = document.getElementById("tunnels-table"); const refreshPortsButton = document.getElementById("refresh-ports"); const refreshTunnelsButton = document.getElementById("refresh-tunnels"); +const refreshEventsButton = document.getElementById("refresh-events"); const debugPanel = document.getElementById("debug-panel"); const debugTitle = document.getElementById("debug-title"); const debugContent = document.getElementById("debug-content"); const REFRESH_INTERVAL_MS = 4000; -let latestTunnels = []; async function fetchJson(url, options) { const response = await fetch(url, options); @@ -50,6 +50,17 @@ function hideDebugPanel() { debugContent.textContent = ""; } +function formatTimestamp(value) { + if (!value) { + return "-"; + } + try { + return new Date(value * 1000).toLocaleString(); + } catch { + return value; + } +} + function renderPorts(ports, tunnels) { if (!ports.length) { portsTable.innerHTML = "
Nessuna porta in ascolto trovata.
"; @@ -65,7 +76,7 @@ function renderPorts(ports, tunnels) { .map((port) => { const tunnel = tunnelMap.get(port.port); const busy = Boolean(tunnel && tunnel.status !== "stopped"); - const statusLabel = busy ? (tunnel.status || "running") : "libero"; + const statusLabel = busy ? escapeHtml(tunnel.status || "running") : "libero"; const hasCommand = Boolean(port.command); const commandCell = hasCommand ? `${escapeHtml(port.command)}` @@ -76,6 +87,12 @@ function renderPorts(ports, tunnels) { const local = escapeHtml(port.local_address || "-"); const service = escapeHtml(port.service || "-"); const protocol = escapeHtml((port.protocol || "tcp").toUpperCase()); + const policyText = port.allowed + ? "consentita" + : escapeHtml(port.policy_reason || "non autorizzata"); + const policyClass = port.allowed ? "status-free" : "status-busy"; + const startDisabled = busy || !port.allowed; + const startTitle = !port.allowed && port.policy_reason ? escapeHtml(port.policy_reason) : ""; return `Nessun evento registrato.
"; + return; + } + + const rows = events + .map((event) => { + const status = escapeHtml(event.status || "-"); + const message = escapeHtml(event.message || "-"); + const timestamp = formatTimestamp(event.created_at); + return ` +Errore: ${error.message}
`; tunnelsTable.innerHTML = `Errore: ${error.message}
`; @@ -223,6 +280,7 @@ async function refreshState() { refreshPortsButton.addEventListener("click", refreshState); refreshTunnelsButton.addEventListener("click", refreshState); +refreshEventsButton.addEventListener("click", refreshState); document.getElementById("debug-close").addEventListener("click", hideDebugPanel); refreshState(); diff --git a/app/static/styles.css b/app/static/styles.css index de6e6d5..ba5dc2b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -91,7 +91,7 @@ button:disabled { } .table.ports .row { - grid-template-columns: 0.6fr 0.7fr 1.3fr 0.9fr 2fr 1fr 1fr 0.8fr 0.9fr; + grid-template-columns: 0.6fr 0.7fr 1.3fr 0.9fr 2fr 1fr 1fr 0.8fr 0.9fr 0.8fr; align-items: center; } @@ -100,6 +100,11 @@ button:disabled { align-items: center; } +.table.events .row { + grid-template-columns: 0.7fr 1fr 2fr 1fr; + align-items: center; +} + .status-pill { padding: 4px 10px; border-radius: 999px; diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..943bb3b --- /dev/null +++ b/app/storage.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import os +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Iterable + + +class Storage: + def __init__(self, path: Path | str) -> None: + path_obj = Path(path).expanduser() + path_obj.parent.mkdir(parents=True, exist_ok=True) + self._path = path_obj + self._conn = sqlite3.connect(str(path_obj), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._lock = threading.Lock() + self._ensure_tables() + + def _ensure_tables(self) -> None: + with self._conn: + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS services ( + port INTEGER PRIMARY KEY, + protocol TEXT, + service TEXT, + pid INTEGER, + user TEXT, + command TEXT, + container TEXT, + local_address TEXT, + allowed INTEGER, + policy_reason TEXT, + updated_at REAL + ) + """, + ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS tunnels ( + port INTEGER PRIMARY KEY, + pid INTEGER, + command TEXT, + status TEXT, + url TEXT, + exit_code INTEGER, + last_output TEXT, + started_at REAL, + stopped_at REAL + ) + """, + ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS tunnel_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + port INTEGER, + status TEXT, + message TEXT, + created_at REAL + ) + """, + ) + + def close(self) -> None: + try: + self._conn.close() + except sqlite3.Error: + pass + + def record_services(self, services: Iterable[dict[str, object | None] | None]) -> None: + rows = [] + now = time.time() + for row in services: + if not row: + continue + rows.append( + ( + row.get("protocol"), + row.get("service"), + row.get("pid"), + row.get("user"), + row.get("command"), + row.get("container"), + row.get("local_address"), + 1 if row.get("allowed") else 0, + row.get("policy_reason"), + now, + row.get("port"), + ) + ) + if not rows: + return + with self._lock, self._conn: + self._conn.executemany( + """ + INSERT INTO services ( + protocol, + service, + pid, + user, + command, + container, + local_address, + allowed, + policy_reason, + updated_at, + port + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(port) DO UPDATE SET + protocol=excluded.protocol, + service=excluded.service, + pid=excluded.pid, + user=excluded.user, + command=excluded.command, + container=excluded.container, + local_address=excluded.local_address, + allowed=excluded.allowed, + policy_reason=excluded.policy_reason, + updated_at=excluded.updated_at + """, + rows, + ) + + def record_tunnel(self, info: object) -> None: + with self._lock, self._conn: + self._conn.execute( + """ + INSERT INTO tunnels ( + port, + pid, + command, + status, + url, + exit_code, + last_output, + started_at, + stopped_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(port) DO UPDATE SET + pid=excluded.pid, + command=excluded.command, + status=excluded.status, + url=excluded.url, + exit_code=excluded.exit_code, + last_output=excluded.last_output, + started_at=excluded.started_at, + stopped_at=excluded.stopped_at + """, + ( + getattr(info, "port", None), + getattr(info, "pid", None), + getattr(info, "command", None), + getattr(info, "status", None), + getattr(info, "url", None), + getattr(info, "exit_code", None), + getattr(info, "last_output", None), + getattr(info, "started_at", None), + getattr(info, "stopped_at", None), + ), + ) + + def log_event(self, port: int, status: str, message: str | None = None) -> None: + timestamp = time.time() + with self._lock, self._conn: + self._conn.execute( + """ + INSERT INTO tunnel_events (port, status, message, created_at) + VALUES (?, ?, ?, ?) + """, + (port, status, message, timestamp), + ) + + @classmethod + def from_env(cls) -> "Storage": + path = os.environ.get("TUNNEL_DB_PATH", "data/dash.db") + return cls(path) + + def recent_events(self, limit: int = 40) -> list[dict[str, object | None]]: + with self._lock, self._conn: + cursor = self._conn.execute( + """ + SELECT port, status, message, created_at + FROM tunnel_events + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ) + return [ + { + "port": row["port"], + "status": row["status"], + "message": row["message"], + "created_at": row["created_at"], + } + for row in cursor.fetchall() + ] + + def recent_services(self, limit: int = 40) -> list[dict[str, object | None]]: + with self._lock, self._conn: + cursor = self._conn.execute( + """ + SELECT port, protocol, service, user, command, container, local_address, allowed, policy_reason, updated_at + FROM services + ORDER BY updated_at DESC + LIMIT ? + """, + (limit,), + ) + return [ + { + "port": row["port"], + "protocol": row["protocol"], + "service": row["service"], + "user": row["user"], + "command": row["command"], + "container": row["container"], + "local_address": row["local_address"], + "allowed": bool(row["allowed"]), + "policy_reason": row["policy_reason"], + "updated_at": row["updated_at"], + } + for row in cursor.fetchall() + ] diff --git a/app/templates/index.html b/app/templates/index.html index f426d41..93fbc8d 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -21,17 +21,25 @@ -