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 `
@@ -90,7 +107,12 @@ function renderPorts(ports, tunnels) { ${statusLabel}
- + ${policyText} +
+
+
`; @@ -107,6 +129,7 @@ function renderPorts(ports, tunnels) {
Container
Indirizzo locale
Tunnel
+
Policy
Azioni
${rows} @@ -206,15 +229,49 @@ function renderTunnels(tunnels) { }); } +function renderEvents(events) { + if (!events.length) { + eventsTable.innerHTML = "

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 ` +
+
${event.port || "-"}
+
${status}
+
${message}
+
${timestamp}
+
+ `; + }) + .join(""); + + eventsTable.innerHTML = ` +
+
Porta
+
Evento
+
Messaggio
+
Data
+
+ ${rows} + `; +} + async function refreshState() { try { - const [ports, tunnels] = await Promise.all([ + const [ports, tunnels, events] = await Promise.all([ fetchJson("/api/ports"), fetchJson("/api/tunnels"), + fetchJson("/api/history/events"), ]); - latestTunnels = tunnels; renderPorts(ports, tunnels); renderTunnels(tunnels); + renderEvents(events); } catch (error) { portsTable.innerHTML = `

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 @@
-
-
-

Tunnel attivi

- -
-
-
- -
-
-

+
+
+

Tunnel attivi

+ +
+
+
+ +
+
+

Audit tunnel

+ +
+
+
+ +
+
+


diff --git a/app/tunnel_manager.py b/app/tunnel_manager.py
index 9a034e6..9c62399 100644
--- a/app/tunnel_manager.py
+++ b/app/tunnel_manager.py
@@ -1,129 +1,163 @@
-import os
-import re
-import shlex
-import subprocess
-import threading
-import time
-from collections import deque
-from dataclasses import dataclass, field
-from typing import Deque, Dict, Optional
-
-URL_REGEX = re.compile(r"https?://\S+")
-
-
-@dataclass
-class TunnelInfo:
-    port: int
-    command: str
-    pid: int
-    url: Optional[str] = None
-    status: str = "running"
-    exit_code: Optional[int] = None
-    last_output: Optional[str] = None
-    started_at: float = field(default_factory=time.time)
-    logs: Deque[str] = field(default_factory=lambda: deque(maxlen=200))
-
-
-class TunnelManager:
-    def __init__(self) -> None:
-        self._processes: Dict[int, subprocess.Popen[str]] = {}
-        self._info: Dict[int, TunnelInfo] = {}
-        self._lock = threading.Lock()
-
-    def list_tunnels(self) -> Dict[int, TunnelInfo]:
-        with self._lock:
-            return dict(self._info)
-
-    def get_logs(self, port: int) -> Optional[list[str]]:
-        with self._lock:
-            info = self._info.get(port)
-            if not info:
-                return None
-            return list(info.logs)
-
-    def start_tunnel(self, port: int) -> TunnelInfo:
-        with self._lock:
-            if port in self._processes and self._processes[port].poll() is None:
-                return self._info[port]
-
-            command_template = os.environ.get(
-                "TUNNEL_COMMAND",
-                "gradio-tunneling --port {port}",
-            )
-            command = command_template.format(port=port)
-            args = shlex.split(command)
-
-            process = subprocess.Popen(
-                args,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.STDOUT,
-                text=True,
-                bufsize=1,
-                env={
-                    **os.environ,
-                    "PYTHONUNBUFFERED": "1",
-                },
-            )
-            info = TunnelInfo(port=port, command=command, pid=process.pid)
-            self._processes[port] = process
-            self._info[port] = info
-
-            if process.stdout:
-                threading.Thread(
-                    target=self._capture_output,
-                    args=(port, process),
-                    daemon=True,
-                ).start()
-            threading.Thread(
-                target=self._watch_process,
-                args=(port, process),
-                daemon=True,
-            ).start()
-
-            return info
-
-    def stop_tunnel(self, port: int) -> bool:
-        with self._lock:
-            process = self._processes.get(port)
-            if not process:
-                return False
-            if process.poll() is None:
-                process.terminate()
-                try:
-                    process.wait(timeout=10)
-                except subprocess.TimeoutExpired:
-                    process.kill()
-            self._processes.pop(port, None)
-            self._info.pop(port, None)
-            return True
-
-    def shutdown(self) -> None:
-        with self._lock:
-            ports = list(self._processes.keys())
-        for port in ports:
-            self.stop_tunnel(port)
-
-    def _capture_output(self, port: int, process: subprocess.Popen[str]) -> None:
-        if not process.stdout:
-            return
-        for line in process.stdout:
-            match = URL_REGEX.search(line)
-            if match:
-                with self._lock:
-                    info = self._info.get(port)
-                    if info:
-                        info.url = match.group(0).rstrip(".")
-            with self._lock:
-                info = self._info.get(port)
-                if info:
-                    info.last_output = line.rstrip()
-                    info.logs.append(line.rstrip())
-
-    def _watch_process(self, port: int, process: subprocess.Popen[str]) -> None:
-        exit_code = process.wait()
-        with self._lock:
-            info = self._info.get(port)
-            if info:
-                info.exit_code = exit_code
-                if info.status == "running":
-                    info.status = "stopped"
+import os
+import re
+import shlex
+import subprocess
+import threading
+import time
+from collections import deque
+from dataclasses import dataclass, field
+from typing import Deque, Dict, Optional
+
+from app.storage import Storage
+
+URL_REGEX = re.compile(r"https?://\S+")
+
+
+@dataclass
+class TunnelInfo:
+    port: int
+    command: str
+    pid: int
+    url: Optional[str] = None
+    status: str = "running"
+    exit_code: Optional[int] = None
+    last_output: Optional[str] = None
+    started_at: float = field(default_factory=time.time)
+    stopped_at: Optional[float] = None
+    logs: Deque[str] = field(default_factory=lambda: deque(maxlen=200))
+
+
+class TunnelManager:
+    def __init__(self, storage: Storage | None = None) -> None:
+        self._processes: Dict[int, subprocess.Popen[str]] = {}
+        self._info: Dict[int, TunnelInfo] = {}
+        self._lock = threading.Lock()
+        self._storage = storage
+
+    def list_tunnels(self) -> Dict[int, TunnelInfo]:
+        with self._lock:
+            return dict(self._info)
+
+    def get_logs(self, port: int) -> Optional[list[str]]:
+        with self._lock:
+            info = self._info.get(port)
+            if not info:
+                return None
+            return list(info.logs)
+
+    def start_tunnel(self, port: int) -> TunnelInfo:
+        with self._lock:
+            if port in self._processes and self._processes[port].poll() is None:
+                return self._info[port]
+
+            command_template = os.environ.get(
+                "TUNNEL_COMMAND",
+                "gradio-tunneling --port {port}",
+            )
+            command = command_template.format(port=port)
+            args = shlex.split(command)
+
+            process = subprocess.Popen(
+                args,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                text=True,
+                bufsize=1,
+                env={
+                    **os.environ,
+                    "PYTHONUNBUFFERED": "1",
+                },
+            )
+            info = TunnelInfo(port=port, command=command, pid=process.pid)
+            self._processes[port] = process
+            self._info[port] = info
+
+            if process.stdout:
+                threading.Thread(
+                    target=self._capture_output,
+                    args=(port, process),
+                    daemon=True,
+                ).start()
+            threading.Thread(
+                target=self._watch_process,
+                args=(port, process),
+                daemon=True,
+            ).start()
+
+            self._persist_tunnel(info)
+            self._log_event(port, "started", f"Tunnel avviato con {command}")
+            return info
+
+    def stop_tunnel(self, port: int) -> bool:
+        with self._lock:
+            process = self._processes.get(port)
+            if not process:
+                return False
+            info = self._info.get(port)
+            if info:
+                info.status = "stopping"
+                self._persist_tunnel(info)
+                self._log_event(port, "stopping", "Richiesta stop manuale")
+            if process.poll() is None:
+                process.terminate()
+                try:
+                    process.wait(timeout=10)
+                except subprocess.TimeoutExpired:
+                    process.kill()
+        return True
+
+    def shutdown(self) -> None:
+        with self._lock:
+            ports = list(self._processes.keys())
+        for port in ports:
+            self.stop_tunnel(port)
+
+    def _capture_output(self, port: int, process: subprocess.Popen[str]) -> None:
+        if not process.stdout:
+            return
+        for line in process.stdout:
+            match = URL_REGEX.search(line)
+            if match:
+                with self._lock:
+                    info = self._info.get(port)
+                    if info:
+                        info.url = match.group(0).rstrip(".")
+            with self._lock:
+                info = self._info.get(port)
+                if info:
+                    info.last_output = line.rstrip()
+                    info.logs.append(line.rstrip())
+                    self._persist_tunnel(info)
+
+    def _watch_process(self, port: int, process: subprocess.Popen[str]) -> None:
+        exit_code = process.wait()
+        info: TunnelInfo | None = None
+        with self._lock:
+            info = self._info.get(port)
+            if info:
+                info.exit_code = exit_code
+                info.stopped_at = time.time()
+                if info.status != "stopped":
+                    info.status = "stopped"
+        if info:
+            self._persist_tunnel(info)
+            self._log_event(port, "stopped", f"Processo terminato (exit_code={exit_code})")
+        with self._lock:
+            self._processes.pop(port, None)
+            self._info.pop(port, None)
+
+    def _persist_tunnel(self, info: TunnelInfo) -> None:
+        if not self._storage:
+            return
+        try:
+            self._storage.record_tunnel(info)
+        except Exception:
+            pass
+
+    def _log_event(self, port: int, status: str, message: str | None = None) -> None:
+        if not self._storage:
+            return
+        try:
+            self._storage.log_event(port, status, message)
+        except Exception:
+            pass