commit d381c02946a9ec06d0a9c6a9a78c489866e5a255 Author: truetype Date: Thu Feb 5 10:34:34 2026 +0100 feat: land dashboard groundwork diff --git a/DASH.MD b/DASH.MD new file mode 100644 index 0000000..f1e4ccd --- /dev/null +++ b/DASH.MD @@ -0,0 +1,93 @@ +# Gradio Tunnel Dashboard (studio progetto) + +Questo repository contiene una proposta di studio per un servizio Linux che: + +- **scansiona le porte in uso** sul server (inclusi i servizi Docker); +- **mostra i risultati in una dashboard**; +- **permette di selezionare un servizio/porta** e **attivare un tunnel** basato su [gradio-tunneling](https://github.com/arkohut/gradio-tunneling); +- **espone l'URL del tunnel in dashboard**, con controlli per **mantenere** o **spegnere** il tunnel e liberare la risorsa. + +## Obiettivo del progetto + +Costruire un servizio che renda semplice e sicuro l'esposizione temporanea di servizi locali (es. container Docker) tramite tunnel, con un'interfaccia web centralizzata. + +## Architettura proposta + +### 1) Scanner delle porte + +- **Input**: porte in ascolto sul server (TCP/UDP), con informazioni su processo, PID, utente. +- **Fonti consigliate**: + - `ss -ltnp` / `ss -lunp` per porte e processi. + - Docker: `docker ps` + `docker inspect` per mappature porte/servizi. +- **Output**: lista normalizzata con: + - porta, protocollo, PID/processo, + - container (se applicabile), + - metadati (nome servizio, descrizione, tag). + +### 2) Service registry (modello dati) + +- **Store**: SQLite o PostgreSQL. +- **Entità principali**: + - `Service` (nome, porta, protocollo, tipo, metadata) + - `Tunnel` (service_id, status, public_url, created_at, updated_at) + +### 3) Orchestratore Tunnel + +- **Driver**: integrazione con `gradio-tunneling`. +- **Funzioni**: + - `start_tunnel(service)` + - `stop_tunnel(tunnel)` + - `health_check(tunnel)` +- **Process supervision**: + - `systemd` (service unit) oppure job manager interno (process/thread pool). +- **Persistence**: + - mantenere stato e URL pubblico nel DB. + +### 4) Dashboard web + +- **Funzionalità**: + - elenco porte/servizi disponibili + - filtri (per container, protocollo, status tunnel) + - azioni: avvia/termina tunnel + - mostra URL pubblico del tunnel con pulsante copia +- **Stack possibili**: + - Backend: FastAPI/Flask + - Frontend: React/Vue, oppure UI server-side (Jinja2) o Gradio stesso + +### 5) Sicurezza e policy + +- Whitelist di porte consentite. +- Autenticazione (OAuth, basic auth o SSO). +- Audit log per avvio/stop tunnel. +- Rate limiting per creare tunnel. + +## Flusso operativo (happy path) + +1. Scanner rileva porte attive e aggiorna il registry. +2. L'utente apre la dashboard e seleziona un servizio. +3. Il backend avvia `gradio-tunneling` sulla porta selezionata. +4. L'URL pubblico del tunnel viene visualizzato in dashboard. +5. L'utente può interrompere il tunnel e liberare la risorsa. + +## Milestone suggerite + +1. **MVP scanner**: lista porte + metadati base. +2. **MVP dashboard**: tabella + pulsanti avvio/stop. +3. **Tunnel orchestration**: integrazione `gradio-tunneling`. +4. **Stabilità**: gestione processi, retry, health checks. +5. **Security**: auth + policy + audit. + +## Note implementative + +- Per Docker: associare porta -> container tramite inspect o naming convention. +- Prevedere un layer di caching per evitare scansioni continue troppo costose. +- In caso di reboot, ripristinare lo stato dei tunnel o marcarli come inattivi. + +## Prossimi passi + +Se vuoi, posso: + +- generare una struttura iniziale del progetto (backend + frontend); +- creare un prototipo funzionante con API di scansione e avvio tunnel; +- aggiungere un esempio di integrazione con `gradio-tunneling`; +- scrivere i container Docker per sviluppo locale. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..3219c98 --- /dev/null +++ b/README.MD @@ -0,0 +1,69 @@ +# Gradio Tunnel Dashboard + +Dashboard web per un server Linux che individua le porte in ascolto, permette di avviare e fermare tunnel tramite `gradio-tunneling` e mostra l'URL generato, tutto dal browser. + +## Funzionalità principali + +- **Scanner portale intelligente** con caching TTL, riconoscimento del protocollo, utente, comando completo e link al container Docker quando disponibile. +- **Dashboard unificata**: la sezione Porte mostra lo stato di ciascun port, mentre la tabella Tunnel permette di leggere l'output, aprire i log e interrompere i tunnel attivi. +- **Controllo dei tunnel**: pulsanti Avvia/Ferma sincronizzati con lo stato del tunnel, badge visivi per sapere se una porta è occupata e aggiornamenti automatici ogni 4 secondi. +- **Debug integrato**: pannello log per ogni tunnel con pulsante dedicato, e l'URL pubblico viene estratto dai log del processo. + +## Scanner porte e metadati + +Il modulo `app/services.py` utilizza `psutil` per enumerare le connessioni `LISTEN`, deduce protocollo/utente/processo/comando e abbina i PID ai container Docker tramite `/proc//cgroup`. I risultati vengono memorizzati per 2 secondi per ridurre la pressione sulla CPU. + +La risposta `/api/ports` include ora: + +- `port`, `protocol`, `service`, `user`, `pid`, `command`, `container`, `local_address` + +Questi campi alimentano la tabella Porte e aiutano nelle decisioni manuali prima di aprire un tunnel. + +## Interfaccia web + +- La tabella Porte mostra metadati completi e un badge Tunnel che indica se la porta è già in uso. Il pulsante **Avvia tunnel** rimane disabilitato quando un tunnel è presente. +- La tabella Tunnel mostra il PID, lo stato, l'URL pubblico, l'ultimo log e il comando invocato. I pulsanti **Log** e **Ferma** permettono debug e cleanup rapidi. +- Il pannello inferiore espone i log del tunnel selezionato per trovare errori di connessione o capire quando viene stampato l'URL pubblico. + +## Avvio rapido + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +Apri `http://localhost:8000`. + +## Configurazione + +Il comando usato per avviare i tunnel è configurabile con la variabile d'ambiente `TUNNEL_COMMAND`. + +Esempio: + +```bash +export TUNNEL_COMMAND="python -m gradio_tunneling --port {port}" +``` + +Se il comando `python -m gradio_tunneling` non funziona, usa la CLI installata dal pacchetto: + +```bash +export TUNNEL_COMMAND="gradio-tunneling --port {port}" +``` + +L'URL viene rilevato dalla prima stringa che contiene un link HTTP/HTTPS nel log del tunnel. + +La dashboard aggiorna automaticamente lo stato dei tunnel e mostra l'ultimo log per aiutare il debug quando l'URL non è disponibile. + +## 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. + +## Rilevamento servizi Docker + +La tabella delle porte mostra anche il nome del container Docker quando disponibile. Questo richiede che il processo abbia accesso al socket Docker locale. + +## Dipendenze tunneling + +Il progetto usa `gradio-tunneling` (installato da GitHub tramite `requirements.txt`). Se stai usando un ambiente offline, installa manualmente il pacchetto nella venv prima di avviare il servizio. diff --git a/README.pdf b/README.pdf new file mode 100644 index 0000000..2cf4321 Binary files /dev/null and b/README.pdf differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..38da2f2 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/tunnel_manager.cpython-313.pyc b/app/__pycache__/tunnel_manager.cpython-313.pyc new file mode 100644 index 0000000..3750a25 Binary files /dev/null and b/app/__pycache__/tunnel_manager.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..256b99d --- /dev/null +++ b/app/main.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import dataclasses +import subprocess + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from app.services import PortScanner +from app.tunnel_manager import TunnelManager + +app = FastAPI(title="Gradio Tunnel Dashboard") +manager = TunnelManager() +scanner = PortScanner() + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + + +class TunnelRequest(BaseModel): + port: int + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/ports") +async def list_ports() -> list[dict[str, str | int | None]]: + records = scanner.scan() + return [dataclasses.asdict(record) for record in sorted(records, key=lambda item: item.port)] + + +@app.get("/api/tunnels") +async def list_tunnels() -> list[dict[str, str | int | None]]: + tunnels = manager.list_tunnels() + return [ + { + "port": info.port, + "command": info.command, + "pid": info.pid, + "url": info.url, + "status": info.status, + "exit_code": info.exit_code, + "last_output": info.last_output, + } + for info in tunnels.values() + ] + + +@app.get("/api/tunnels/{port}/logs") +async def tunnel_logs(port: int) -> dict[str, list[str]]: + logs = manager.get_logs(port) + if logs is None: + raise HTTPException(status_code=404, detail="Tunnel not running.") + return {"logs": logs} + + +@app.post("/api/tunnels") +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.") + info = manager.start_tunnel(payload.port) + return { + "port": info.port, + "command": info.command, + "pid": info.pid, + "url": info.url, + "status": info.status, + "exit_code": info.exit_code, + "last_output": info.last_output, + } + + +@app.delete("/api/tunnels/{port}") +async def stop_tunnel(port: int) -> dict[str, bool]: + stopped = manager.stop_tunnel(port) + if not stopped: + raise HTTPException(status_code=404, detail="Tunnel not running.") + return {"stopped": True} + + +@app.on_event("shutdown") +def shutdown_event() -> None: + manager.shutdown() diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..5392c6f --- /dev/null +++ b/app/services.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import shlex +import socket +import subprocess +import time +from dataclasses import dataclass +from typing import Iterable, List, Optional + +import psutil + + +@dataclass +class ServiceRecord: + port: int + protocol: str + service: str + pid: Optional[int] + user: Optional[str] + command: Optional[str] + container: Optional[str] + local_address: str + + +class PortScanner: + def __init__(self, cache_ttl: float = 2.0) -> None: + self._cache_ttl = cache_ttl + self._last_scan = 0.0 + self._cache: List[ServiceRecord] = [] + + def scan(self) -> List[ServiceRecord]: + now = time.monotonic() + if self._cache and now - self._last_scan < self._cache_ttl: + return self._cache + self._cache = self._perform_scan() + self._last_scan = now + return self._cache + + def _perform_scan(self) -> List[ServiceRecord]: + connections = psutil.net_connections(kind="inet") + container_names = _load_container_names() + process_cache: dict[int, tuple[str, Optional[str], list[str]]] = {} + seen_ports: set[int] = set() + results: List[ServiceRecord] = [] + + for conn in connections: + if conn.status != psutil.CONN_LISTEN: + continue + if not conn.laddr: + continue + port = conn.laddr.port + if port in seen_ports: + continue + seen_ports.add(port) + protocol = _protocol_for_conn(conn.type) + process_name = "unknown" + process_user: Optional[str] = None + process_command: Optional[str] = None + pid = conn.pid + + if pid and pid > 0: + cache_entry = process_cache.get(pid) + if cache_entry is None: + cache_entry = _describe_process(pid) + process_cache[pid] = cache_entry + process_name, process_user, raw_command = cache_entry + if raw_command: + 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 + + results.append( + ServiceRecord( + port=port, + protocol=protocol, + service=process_name, + pid=pid, + user=process_user, + command=process_command, + container=container, + local_address=local_address, + ) + ) + + return results + + +def _protocol_for_conn(sock_type: int) -> str: + if sock_type == socket.SOCK_STREAM: + return "tcp" + if sock_type == socket.SOCK_DGRAM: + return "udp" + return "inet" + + +def _describe_process(pid: int) -> tuple[str, Optional[str], list[str]]: + try: + process = psutil.Process(pid) + name = process.name() or "unknown" + user = None + try: + user = process.username() + except psutil.Error: + user = None + command = process.cmdline() + except psutil.Error: + return "unknown", None, [] + return name, user, command + + +def _format_commandline(cmdline: Iterable[str]) -> str: + command = " ".join(shlex.quote(part) for part in cmdline if part) + if len(command) > 150: + return command[:150].rstrip() + "…" + return command + + +def _load_container_names() -> dict[str, str]: + try: + result = subprocess.run( + [ + "docker", + "ps", + "--no-trunc", + "--format", + "{{.ID}} {{.Names}}", + ], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return {} + containers: dict[str, str] = {} + for line in result.stdout.splitlines(): + parts = line.strip().split(maxsplit=1) + if len(parts) != 2: + continue + container_id, name = parts + containers[container_id.lower()] = name + return containers + + +def _container_name_for_pid(pid: Optional[int], container_names: dict[str, str]) -> Optional[str]: + if not pid: + return None + try: + with open(f"/proc/{pid}/cgroup", "r", encoding="utf-8") as handle: + content = handle.read() + except OSError: + return None + for line in content.splitlines(): + lower = line.lower() + if not any(keyword in lower for keyword in ("docker", "containerd", "kubepods", "podman")): + continue + for part in line.split("/"): + part = part.strip() + if not part: + continue + if _looks_like_container_id(part): + for container_id, name in container_names.items(): + if container_id.startswith(part.lower()): + return name + return None + + +def _looks_like_container_id(value: str) -> bool: + value = value.strip() + if len(value) < 12: + return False + return all(ch in "0123456789abcdef" for ch in value[:12].lower()) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..fb8ac2a --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,229 @@ +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 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); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = payload.detail || "Errore inatteso."; + throw new Error(detail); + } + return response.json(); +} + +function escapeHtml(value) { + if (!value) { + return ""; + } + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +async function loadTunnelLogs(port) { + try { + const payload = await fetchJson(`/api/tunnels/${port}/logs`); + debugTitle.textContent = `Log tunnel ${port}`; + debugContent.textContent = payload.logs.join("\n") || "Nessun log disponibile."; + debugPanel.classList.add("visible"); + } catch (error) { + debugTitle.textContent = `Log tunnel ${port}`; + debugContent.textContent = `Errore: ${error.message}`; + debugPanel.classList.add("visible"); + } +} + +function hideDebugPanel() { + debugPanel.classList.remove("visible"); + debugTitle.textContent = ""; + debugContent.textContent = ""; +} + +function renderPorts(ports, tunnels) { + if (!ports.length) { + portsTable.innerHTML = "

Nessuna porta in ascolto trovata.

"; + return; + } + + const tunnelMap = new Map(); + tunnels.forEach((tunnel) => { + tunnelMap.set(tunnel.port, tunnel); + }); + + const rows = ports + .map((port) => { + const tunnel = tunnelMap.get(port.port); + const busy = Boolean(tunnel && tunnel.status !== "stopped"); + const statusLabel = busy ? (tunnel.status || "running") : "libero"; + const hasCommand = Boolean(port.command); + const commandCell = hasCommand + ? `${escapeHtml(port.command)}` + : "-"; + const commandClass = hasCommand ? "" : "dim"; + const user = escapeHtml(port.user || "-"); + const container = escapeHtml(port.container || "-"); + const local = escapeHtml(port.local_address || "-"); + const service = escapeHtml(port.service || "-"); + const protocol = escapeHtml((port.protocol || "tcp").toUpperCase()); + + return ` +
+
${port.port}
+
${protocol}
+
${service}
+
${user}
+
${commandCell}
+
${container}
+
${local}
+
+ ${statusLabel} +
+
+ +
+
+ `; + }) + .join(""); + + portsTable.innerHTML = ` +
+
Porta
+
Protocollo
+
Servizio
+
Utente
+
Comando
+
Container
+
Indirizzo locale
+
Tunnel
+
Azioni
+
+ ${rows} + `; + + portsTable.querySelectorAll(".start-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + button.disabled = true; + try { + await fetchJson("/api/tunnels", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ port }), + }); + await refreshState(); + } catch (error) { + alert(error.message); + } finally { + button.disabled = false; + } + }); + }); +} + +function renderTunnels(tunnels) { + if (!tunnels.length) { + tunnelsTable.innerHTML = "

Nessun tunnel attivo.

"; + return; + } + + const rows = tunnels + .map((tunnel) => { + const hasCommand = Boolean(tunnel.command); + const commandCell = hasCommand + ? `${escapeHtml(tunnel.command)}` + : "-"; + const commandClass = hasCommand ? "" : "dim"; + const url = tunnel.url + ? `${escapeHtml( + tunnel.url, + )}` + : "In attesa..."; + const lastOutput = escapeHtml(tunnel.last_output || "-"); + const status = escapeHtml(tunnel.status || "running"); + + return ` +
+
${tunnel.port}
+
${tunnel.pid || "-"}
+
${status}
+
${url}
+
${lastOutput}
+
${commandCell}
+
+ + +
+
+ `; + }) + .join(""); + + tunnelsTable.innerHTML = ` +
+
Porta
+
PID
+
Stato
+
URL
+
Ultimo log
+
Comando
+
Azioni
+
+ ${rows} + `; + + tunnelsTable.querySelectorAll(".logs-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + await loadTunnelLogs(port); + }); + }); + + tunnelsTable.querySelectorAll(".stop-button").forEach((button) => { + button.addEventListener("click", async () => { + const port = Number(button.dataset.port); + button.disabled = true; + try { + await fetchJson(`/api/tunnels/${port}`, { method: "DELETE" }); + await refreshState(); + } catch (error) { + alert(error.message); + } finally { + button.disabled = false; + } + }); + }); +} + +async function refreshState() { + try { + const [ports, tunnels] = await Promise.all([ + fetchJson("/api/ports"), + fetchJson("/api/tunnels"), + ]); + latestTunnels = tunnels; + renderPorts(ports, tunnels); + renderTunnels(tunnels); + } catch (error) { + portsTable.innerHTML = `

Errore: ${error.message}

`; + tunnelsTable.innerHTML = `

Errore: ${error.message}

`; + } +} + +refreshPortsButton.addEventListener("click", refreshState); +refreshTunnelsButton.addEventListener("click", refreshState); +document.getElementById("debug-close").addEventListener("click", hideDebugPanel); + +refreshState(); +setInterval(refreshState, REFRESH_INTERVAL_MS); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..de6e6d5 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,160 @@ +:root { + color-scheme: light; + font-family: "Inter", "Segoe UI", sans-serif; + background: #f4f6fb; + color: #1a1d2b; +} + +body { + margin: 0; +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 24px 48px; +} + +header { + margin-bottom: 24px; +} + +header h1 { + margin: 0 0 8px; + font-size: 2rem; +} + +header p { + margin: 0; + color: #3a3f55; +} + +.card { + background: #ffffff; + border-radius: 16px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 14px 40px rgba(31, 39, 67, 0.08); +} + +.debug-panel { + display: none; +} + +.debug-panel.visible { + display: block; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.card-header h2 { + margin: 0; + font-size: 1.25rem; +} + +button { + border: none; + border-radius: 8px; + padding: 8px 14px; + background: #4c6fff; + color: white; + font-weight: 600; + cursor: pointer; +} + +.logs-button { + background: #2f3b5b; + margin-right: 8px; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.table { + display: grid; + gap: 8px; +} + +.row { + display: grid; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + background: #f9fafc; +} + +.table.ports .row { + grid-template-columns: 0.6fr 0.7fr 1.3fr 0.9fr 2fr 1fr 1fr 0.8fr 0.9fr; + align-items: center; +} + +.table.tunnels .row { + grid-template-columns: 0.8fr 1fr 1fr 2fr 2fr 1.5fr 1fr; + align-items: center; +} + +.status-pill { + padding: 4px 10px; + border-radius: 999px; + font-size: 0.85rem; + text-transform: capitalize; + display: inline-block; +} + +.status-free { + background: rgba(76, 111, 255, 0.15); + color: #4751ef; +} + +.status-busy { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} + +.mono { + font-family: \"JetBrains Mono\", \"JetBrains Mono\", Menlo, Consolas, monospace; + font-size: 0.9rem; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 16rem; +} + +.dim { + color: #64748b; +} + +.row.header { + background: transparent; + font-weight: 700; +} + +.cell a { + color: #2d5bff; + text-decoration: none; +} + +.debug-content { + background: #0f172a; + color: #e2e8f0; + padding: 16px; + border-radius: 12px; + font-size: 0.85rem; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; +} + +@media (max-width: 720px) { + .row { + grid-template-columns: 1fr; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..f426d41 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,43 @@ + + + + + + Gradio Tunnel Dashboard + + + +
+
+

Gradio Tunnel Dashboard

+

Scansione porte attive e gestione tunnel Gradio.

+
+ +
+
+

Porte in ascolto

+ +
+
+
+ +
+
+

Tunnel attivi

+ +
+
+
+ +
+
+

+ +
+

+      
+
+ + + + diff --git a/app/tunnel_manager.py b/app/tunnel_manager.py new file mode 100644 index 0000000..9a034e6 --- /dev/null +++ b/app/tunnel_manager.py @@ -0,0 +1,129 @@ +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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..303a66e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +psutil==6.0.0 +jinja2==3.1.4