feat: add persistence and audit trails

This commit is contained in:
truetype committed 2026-02-05 11:07:20 +01:00
1 parent d381c02946
commit 2f2e9ab3e3
9 files changed
+633 -151

No files matched your search

+21
View File
@@ -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.
+20 -3
View File
@@ -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()
+84
View File
@@ -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
+30 -2
View File
@@ -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:
+63 -5
View File
@@ -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 = "<p>Nessuna porta in ascolto trovata.</p>";
@@ -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
? `<span class="mono">${escapeHtml(port.command)}</span>`
@@ -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 `
<div class="row">
@@ -90,7 +107,12 @@ function renderPorts(ports, tunnels) {
<span class="status-pill ${busy ? "status-busy" : "status-free"}">${statusLabel}</span>
</div>
<div class="cell">
<button data-port="${port.port}" class="start-button" ${busy ? "disabled" : ""}>Avvia tunnel</button>
<span class="status-pill ${policyClass}">${policyText}</span>
</div>
<div class="cell">
<button data-port="${port.port}" class="start-button" ${startDisabled ? "disabled" : ""} ${
startTitle ? `title="${startTitle}"` : ""
}>Avvia tunnel</button>
</div>
</div>
`;
@@ -107,6 +129,7 @@ function renderPorts(ports, tunnels) {
<div class="cell">Container</div>
<div class="cell">Indirizzo locale</div>
<div class="cell">Tunnel</div>
<div class="cell">Policy</div>
<div class="cell">Azioni</div>
</div>
${rows}
@@ -206,15 +229,49 @@ function renderTunnels(tunnels) {
});
}
function renderEvents(events) {
if (!events.length) {
eventsTable.innerHTML = "<p>Nessun evento registrato.</p>";
return;
}
const rows = events
.map((event) => {
const status = escapeHtml(event.status || "-");
const message = escapeHtml(event.message || "-");
const timestamp = formatTimestamp(event.created_at);
return `
<div class="row">
<div class="cell">${event.port || "-"}</div>
<div class="cell">${status}</div>
<div class="cell">${message}</div>
<div class="cell">${timestamp}</div>
</div>
`;
})
.join("");
eventsTable.innerHTML = `
<div class="row header">
<div class="cell">Porta</div>
<div class="cell">Evento</div>
<div class="cell">Messaggio</div>
<div class="cell">Data</div>
</div>
${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 = `<p>Errore: ${error.message}</p>`;
tunnelsTable.innerHTML = `<p>Errore: ${error.message}</p>`;
@@ -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();
+6 -1
View File
@@ -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;
+227
View File
@@ -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()
]
+19 -11
View File
@@ -21,17 +21,25 @@
<div class="table ports" id="ports-table"></div>
</section>
<section class="card">
<div class="card-header">
<h2>Tunnel attivi</h2>
<button id="refresh-tunnels">Aggiorna</button>
</div>
<div class="table tunnels" id="tunnels-table"></div>
</section>
<section class="card debug-panel" id="debug-panel">
<div class="card-header">
<h2 id="debug-title"></h2>
<section class="card">
<div class="card-header">
<h2>Tunnel attivi</h2>
<button id="refresh-tunnels">Aggiorna</button>
</div>
<div class="table tunnels" id="tunnels-table"></div>
</section>
<section class="card">
<div class="card-header">
<h2>Audit tunnel</h2>
<button id="refresh-events">Aggiorna</button>
</div>
<div class="table events" id="events-table"></div>
</section>
<section class="card debug-panel" id="debug-panel">
<div class="card-header">
<h2 id="debug-title"></h2>
<button id="debug-close">Chiudi</button>
</div>
<pre id="debug-content" class="debug-content"></pre>
+163 -129
View File
@@ -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