feat: add persistence and audit trails
This commit is contained in:
1 parent
d381c02946
commit
2f2e9ab3e3
9 files changed
+633
-151
No files matched your search
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user