Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
Claude Agent b14e7bc50a feat(discord-bridge): dashboard fara token (DASHBOARD_AUTH=off)
Cerut explicit: tokenul nu se retine. Cu DASHBOARD_AUTH=off nu mai exista login,
/login.html duce inapoi la panou, iar butonul "Iesi" dispare.

Se sprijina pe doua lucruri si nu are sens fara ele: serviciul e legat de
127.0.0.1, deci din retea ajunge la el doar tailscaled; iar tailscale serve il
publica tainet only, unde accesul e deja autentificat de Tailscale.

Compensatie partiala pentru ce se pierde: fiecare start/stop/restart se scrie in
logs/dashboard.log cu identitatea din antetul Tailscale-User-Login pus de
tailscale serve (verificat: ajunge pana la noi). Antetul e DOAR pentru jurnal —
nu decide accesul, fiindca un proces local l-ar putea fabrica.

Implicitul ramane cu token: doar off/none/0/false scot login-ul, orice alta
valoare il pastreaza (are test).

Sase teste noi, 41 pe dashboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 14:16:20 +00:00

629 lines
24 KiB
Python

#!/usr/bin/env python3
"""Dashboard de control pentru puntea Discord -> Claude Code (LXC 171).
Model: `echo-core/dashboard` de pe LXC 110 (server stdlib + handler-ul `eco.py`).
Diferentele deliberate:
- **o singura unitate controlata**: `claude-discord.service`. Nu exista endpoint
care sa primeasca un nume de unit din exterior — altfel dashboard-ul ar deveni
un `systemctl` remote fara parola.
- **bind pe 127.0.0.1 implicit**: butonul "restart" opreste un agent care ruleaza
cu `bypassPermissions` si chei SSH catre tot clusterul. Accesul se face prin
tunel SSH (vezi README), nu expus in LAN.
- **restart protejat de tururi in zbor**: `stop`/`restart` intorc 409 daca exista
fire cu tur in desfasurare, pana cand se cere explicit `force`.
Fara dependinte in afara stdlib: ruleaza cu acelasi python ca botul, dar nu are
nevoie de venv-ul lui.
"""
from __future__ import annotations
import json
import os
import secrets
import shutil
import subprocess
import sys
import threading
import time
from datetime import datetime
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# Radacina punti (parintele lui dashboard/) trebuie sa fie importabila: de acolo
# vin config.py, cleanup.py si security/approvals.py.
_DASH = Path(__file__).resolve().parent
_BRIDGE = _DASH.parent
for _p in (str(_BRIDGE), str(_DASH)):
if _p not in sys.path:
sys.path.insert(0, _p)
import config # noqa: E402
# ── constante ───────────────────────────────────────────────────────────
SERVICE = "claude-discord.service"
SELF_SERVICE = "claude-discord-dashboard.service"
# Caile se recalculeaza la fiecare apel, nu se ingheata la import: `config.reload()`
# muta STATE_DIR (testele o folosesc ca sa scoata totul din ~/.claude-discord).
def bot_log() -> Path:
return config.LOG_DIR / "bot.log"
def infra_log() -> Path:
return config.LOG_DIR / "infra.log"
COOKIE_NAME = "dashboard"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
def mount_prefix() -> str:
"""Prefixul sub care e montat panoul (`DASHBOARD_PREFIX`, ex. `/claude`).
`tailscale serve --set-path /claude` TAIE prefixul inainte de a proxa, deci in
mod normal aici ajunge `/api/status`. Prefixul e acceptat totusi si intact,
pentru cazul unui proxy care nu taie si pentru `curl` direct pe localhost.
Paginile nu depind de el: toate URL-urile din HTML sunt relative.
"""
pfx = (config.get("DASHBOARD_PREFIX") or "").strip().rstrip("/")
if pfx and not pfx.startswith("/"):
pfx = "/" + pfx
return pfx
def strip_prefix(path: str) -> str:
pfx = mount_prefix()
if pfx and (path == pfx or path.startswith(pfx + "/")):
return path[len(pfx):] or "/"
return path
_TOKEN: str | None = None
def reset_token_cache() -> None:
"""Uita tokenul memorat (folosit de teste dupa `config.reload`)."""
global _TOKEN
_TOKEN = None
def auth_disabled() -> bool:
"""`DASHBOARD_AUTH=off` in `~/.claude-discord/env` scoate complet login-ul.
Alegere constienta a operatorului, nu implicit: panoul poate opri un agent cu
`bypassPermissions` si chei SSH catre tot clusterul, deci fara token oricine
ajunge la port il poate folosi. Are sens doar pentru ca serviciul e legat de
`127.0.0.1` si publicat exclusiv in tailnet (`tailscale serve`, tainet only),
unde identitatea o face deja Tailscale.
"""
return (config.get("DASHBOARD_AUTH") or "").strip().lower() in ("off", "none", "0", "false")
def dashboard_token() -> str:
"""Tokenul de acces, din `~/.claude-discord/env` (`DASHBOARD_TOKEN`).
Lipsa lui NU deschide dashboard-ul: se genereaza unul aleator per proces si se
tipareste in log, deci ramane accesibil doar cui poate citi logul. Ca sa fie
deschis intentionat, se pune `DASHBOARD_AUTH=off`.
"""
global _TOKEN
if _TOKEN is None:
tok = (config.get("DASHBOARD_TOKEN") or "").strip()
if not tok:
tok = secrets.token_urlsafe(32)
print(
f"[auth] DASHBOARD_TOKEN nesetat in {config.ENV_FILE} — token efemer "
f"pentru acest proces: {tok}",
file=sys.stderr, flush=True,
)
_TOKEN = tok
return _TOKEN
def tailnet_user(headers) -> str:
"""Cine e, dupa Tailscale. `tailscale serve` pune antetul pe cererile din
tailnet; pe localhost lipseste. Doar pentru jurnal — nu e folosit ca decizie
de acces, fiindca un proces local ar putea sa-l fabrice."""
return (headers.get("Tailscale-User-Login") or "").strip()
# ── systemd ─────────────────────────────────────────────────────────────
def _sysctl(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", "--user", *args],
capture_output=True, text=True, timeout=timeout,
)
def _show(unit: str, prop: str) -> str:
try:
return _sysctl("show", "-p", prop, "--value", unit, timeout=5).stdout.strip()
except Exception:
return ""
def _uptime_s(unit: str) -> int | None:
"""Secunde de la ultima pornire a unitatii.
Sursa principala e `ActiveEnterTimestampMonotonic` (microsecunde pe
CLOCK_MONOTONIC), comparat cu `time.monotonic()` — ACELASI ceas. **Nu** se
foloseste `/proc/uptime`: intr-un LXC acela e virtualizat de lxcfs si arata
uptime-ul containerului, mai mic decat monotonic-ul gazdei pe care il
raporteaza systemd, deci diferenta iese negativa si uptime-ul apare 0.
Rezerva e `ActiveEnterTimestamp`, ora de perete in fusul local al masinii cu
numele fusului la coada; il taiem si lasam `.timestamp()` sa-l interpreteze
ca ora locala (`%Z` in strptime nu produce un offset utilizabil).
"""
mono = _show(unit, "ActiveEnterTimestampMonotonic")
if mono.isdigit() and int(mono) > 0:
return max(0, int(time.monotonic() - int(mono) / 1_000_000))
ts = _show(unit, "ActiveEnterTimestamp")
if not ts:
return None
try:
parts = ts.split()
# "Sun 2026-08-30 12:47:20 UTC" -> data + ora, fara ziua si fusul
stamp = datetime.strptime(f"{parts[1]} {parts[2]}", "%Y-%m-%d %H:%M:%S")
except (ValueError, IndexError):
return None
return max(0, int(time.time() - stamp.timestamp()))
def unit_info(unit: str) -> dict:
active = _show(unit, "ActiveState")
pid = _show(unit, "MainPID")
mem = _show(unit, "MemoryCurrent")
restarts = _show(unit, "NRestarts")
return {
"unit": unit,
"active": active == "active",
"state": active or "unknown",
"sub": _show(unit, "SubState"),
"enabled": _show(unit, "UnitFileState"),
"pid": int(pid) if pid.isdigit() and pid != "0" else None,
"memory_bytes": int(mem) if mem.isdigit() else None,
"restarts": int(restarts) if restarts.isdigit() else 0,
"uptime_s": _uptime_s(unit) if active == "active" else None,
}
# ── starea punti ────────────────────────────────────────────────────────
def read_state() -> dict:
"""state.json fara lock: dashboard-ul doar citeste si nu are voie sa blocheze
botul. Un JSON prins la mijlocul unei scrieri intoarce {} — se reincarca la
urmatorul poll."""
try:
data = json.loads(config.STATE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _pid_alive(pid) -> bool:
try:
return pid is not None and Path(f"/proc/{int(pid)}").exists()
except (TypeError, ValueError):
return False
def threads_view(state: dict) -> list[dict]:
out = []
for tid, rec in (state.get("threads") or {}).items():
if not isinstance(rec, dict):
continue
inflight = rec.get("inflight") or None
out.append({
"thread_id": tid,
"cwd": rec.get("cwd"),
"model": rec.get("model"),
"pid": rec.get("pid"),
"alive": _pid_alive(rec.get("pid")),
"inflight": bool(inflight),
"inflight_since": (inflight or {}).get("started_at"),
"cost_usd": round(float(rec.get("cost_usd_total") or 0), 4),
"last_active": rec.get("last_active"),
})
out.sort(key=lambda t: t.get("last_active") or 0, reverse=True)
return out
def inflight_threads(state: dict) -> list[str]:
return [t["thread_id"] for t in threads_view(state) if t["inflight"]]
def pending_approvals() -> list[dict]:
"""Cererile de confirmare in asteptare, citite direct din director.
Nu importam `security.approvals` (API-ul lui e async si porneste un watcher);
formatul fisierului e fixat in security/README.md.
"""
out = []
d = config.APPROVALS_DIR
try:
files = sorted(d.glob("*.json"))
except OSError:
return out
now = time.time()
for f in files:
try:
req = json.loads(f.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if not isinstance(req, dict) or req.get("status") != "pending":
continue
out.append({
"request_id": req.get("request_id"),
"thread_id": req.get("thread_id"),
"tool_name": req.get("tool_name"),
"command": (req.get("command") or "")[:500],
"rule": req.get("rule"),
"reason": req.get("reason"),
"created_at": req.get("created_at"),
"expires_in": (
round(req["expires_at"] - now, 1)
if isinstance(req.get("expires_at"), (int, float)) else None
),
})
return out
def doctor() -> list[dict]:
checks: list[dict] = []
info = unit_info(SERVICE)
checks.append({
"name": "Serviciu claude-discord",
"pass": info["active"],
"detail": f'{info["state"]}/{info["sub"]}, {info["restarts"]} restarturi',
})
st = read_state()
checks.append({
"name": "state.json",
"pass": bool(st),
"detail": f'{len(st.get("threads") or {})} fire' if st else "ilizibil sau gol",
})
cap = float(config.get("COST_CAP_USD_DAY") or 0)
spent = float((st.get("cost") or {}).get("usd") or 0)
checks.append({
"name": "Plafon de cost pe zi",
"pass": cap <= 0 or spent < cap,
"detail": f"{spent:.2f} / {cap:.2f} USD",
})
try:
du = shutil.disk_usage("/")
pct = du.free / du.total * 100
checks.append({
"name": "Spatiu pe disc",
"pass": pct > 10,
"detail": f"{pct:.1f}% liber ({du.free // 1024**3} GB)",
})
except OSError as exc:
checks.append({"name": "Spatiu pe disc", "pass": False, "detail": str(exc)})
try:
log = bot_log()
size_mb = log.stat().st_size / 1024**2 if log.exists() else 0
checks.append({
"name": "bot.log",
"pass": log.exists() and size_mb < 100,
"detail": f"{size_mb:.1f} MB" if log.exists() else "lipseste",
})
except OSError as exc:
checks.append({"name": "bot.log", "pass": False, "detail": str(exc)})
# Regula deny(ssh) a mai taiat o data accesul la infrastructura — vezi
# security/README.md. Verificam sa nu reapara la o editare viitoare.
try:
settings = json.loads(config.SETTINGS_FILE.read_text(encoding="utf-8"))
deny = (settings.get("permissions") or {}).get("deny") or []
bad = [d for d in deny if d.startswith(("Bash(ssh", "Bash(scp"))]
checks.append({
"name": "Reguli deny in bot-settings.json",
"pass": not bad,
"detail": f"blocheaza infrastructura: {bad}" if bad else f"{len(deny)} reguli, ssh liber",
})
except (OSError, ValueError) as exc:
checks.append({"name": "Reguli deny in bot-settings.json", "pass": False, "detail": str(exc)})
hook = _BRIDGE / "security" / "confirm_hook.py"
checks.append({
"name": "Hook de confirmare",
"pass": hook.exists(),
"detail": str(hook) if hook.exists() else "lipseste",
})
claude_bin = shutil.which(config.get("CLAUDE_BIN") or "claude")
checks.append({
"name": "CLI claude",
"pass": bool(claude_bin),
"detail": claude_bin or "nu e in PATH",
})
return checks
def orphans_report(dry_run: bool = True) -> dict:
"""`/cleanup` din Discord, expus si aici. Importul e lenes fiindca modulul
citeste /proc la import-time in unele cai."""
import cleanup # noqa: PLC0415
found = cleanup.find_orphans(read_state())
results = None
if not dry_run and found:
results = cleanup.kill_orphans(found, dry_run=False)
return {
"orphans": found,
"killed": results,
"report": cleanup.format_report(found, results),
}
# ── HTTP ────────────────────────────────────────────────────────────────
def _parse_cookies(raw: str) -> dict[str, str]:
out: dict[str, str] = {}
for chunk in (raw or "").split(";"):
chunk = chunk.strip()
if "=" in chunk:
k, v = chunk.split("=", 1)
out[k.strip()] = v.strip()
return out
class Handler(SimpleHTTPRequestHandler):
server_version = "claude-discord-dashboard"
protocol_version = "HTTP/1.1"
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(_DASH), **kw)
# --- utilitare -----------------------------------------------------
def log_message(self, format, *args): # jurnal compact, o linie # noqa: A002
sys.stderr.write("%s %s\n" % (self.address_string(), format % args))
def send_json(self, payload, status: int = 200, extra_headers: dict | None = None):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def read_json(self) -> dict:
try:
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode("utf-8") if n > 0 else ""
data = json.loads(raw) if raw else {}
return data if isinstance(data, dict) else {}
except (ValueError, OSError, UnicodeDecodeError):
return {}
def authed(self) -> bool:
if auth_disabled():
return True
got = _parse_cookies(self.headers.get("Cookie", "")).get(COOKIE_NAME, "")
return bool(got) and secrets.compare_digest(got, dashboard_token())
def deny(self):
self.send_json({"error": "neautentificat"}, 401)
# --- GET -----------------------------------------------------------
def do_GET(self):
raw = urlparse(self.path).path
path = strip_prefix(raw)
if path == "/" and not raw.endswith("/"):
# `/claude` fara slash final: URL-urile relative din pagina s-ar
# rezolva la radacina hostului (`/api/status`), unde proxy-ul nu mai
# trimite nimic incoace. Fortam forma cu slash.
self.send_response(301)
self.send_header("Location", raw + "/")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path.startswith("/api/"):
if not self.authed():
return self.deny()
return self.route_get(path)
if path in ("/", "/index.html") and not self.authed():
# Prefixul reintra AICI in mod deliberat. Un "/login.html" absolut ar
# arunca browserul in radacina hostului (alt serviciu), iar un
# "login.html" relativ se rezolva gresit cand adresa vine fara slash
# final (`/claude` -> `/login.html`). Cu prefixul reatasat, ambele
# forme ajung unde trebuie, si direct pe localhost la fel: calea de
# intrare e curatata oricum de `strip_prefix`.
self.send_response(302)
self.send_header("Location", mount_prefix() + "/login.html")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path in ("/", "/index.html"):
return self.send_html("index.html")
if path == "/login.html":
if auth_disabled():
self.send_response(302)
self.send_header("Location", mount_prefix() + "/")
self.send_header("Content-Length", "0")
self.end_headers()
return None
return self.send_html("login.html")
self.path = path
return super().do_GET()
def send_html(self, name: str):
"""Trimite o pagina, cu `<base href>` pus la servire.
De ce e nevoie: `tailscale serve --set-path /claude` TAIE prefixul, deci
serverul nu poate sti daca browserul e la `/claude` sau la `/claude/` —
ambele ajung aici ca `/`. Fara slash final, un URL relativ (`api/status`)
se rezolva la radacina hostului, unde proxy-ul nu mai trimite nimic
incoace: pagina se incarca si ramane goala, fara nicio eroare vizibila.
`<base href="/claude/">` fixeaza rezolvarea indiferent de forma adresei.
"""
try:
body = (_DASH / name).read_text(encoding="utf-8")
except OSError:
return self.send_error(404)
pfx = mount_prefix()
if pfx:
body = body.replace("<head>", f'<head>\n<base href="{pfx}/">', 1)
data = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
# no-store: paginile poarta acum si configuratia (base href), deci o
# copie veche din cache ar trimite cererile in alta parte.
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError):
pass
def route_get(self, path: str):
qs = parse_qs(urlparse(self.path).query)
if path == "/api/status":
state = read_state()
return self.send_json({
"service": unit_info(SERVICE),
"dashboard": unit_info(SELF_SERVICE),
"threads": threads_view(state),
"cost": state.get("cost") or {},
"cost_cap": float(config.get("COST_CAP_USD_DAY") or 0),
"pending_approvals": len(pending_approvals()),
"auth": not auth_disabled(),
"user": tailnet_user(self.headers),
"now": time.time(),
})
if path == "/api/logs":
try:
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
except ValueError:
n = 200
which = qs.get("file", ["bot"])[0]
target = infra_log() if which == "infra" else bot_log()
if not target.exists():
return self.send_json({"lines": [f"({target} nu exista)"]})
r = subprocess.run(["tail", "-n", str(n), str(target)],
capture_output=True, text=True, timeout=15)
return self.send_json({"file": target.name, "lines": r.stdout.splitlines()})
if path == "/api/approvals":
return self.send_json({"approvals": pending_approvals()})
if path == "/api/doctor":
return self.send_json({"checks": doctor()})
if path == "/api/cleanup":
return self.send_json(orphans_report(dry_run=True))
return self.send_json({"error": "ruta necunoscuta"}, 404)
# --- POST ----------------------------------------------------------
def do_POST(self):
path = strip_prefix(urlparse(self.path).path)
if path == "/api/auth/login":
return self.handle_login()
if path == "/api/auth/logout":
return self.send_json(
{"ok": True},
extra_headers={"Set-Cookie": f"{COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"},
)
if not self.authed():
return self.deny()
if path == "/api/service":
return self.handle_service()
if path == "/api/cleanup":
data = self.read_json()
return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True))))
if path == "/api/approvals/decide":
return self.handle_decide()
if path == "/api/restart-self":
self.send_json({"ok": True, "message": "dashboard-ul reporneste in 1s"})
threading.Thread(target=lambda: (time.sleep(1), os._exit(0)), daemon=True).start()
return None
return self.send_json({"error": "ruta necunoscuta"}, 404)
def handle_login(self):
data = self.read_json()
provided = (data.get("token") or "").strip()
if not provided or not secrets.compare_digest(provided, dashboard_token()):
time.sleep(0.5) # incetineste ghicitul
return self.send_json({"error": "token invalid"}, 401)
cookie = (f"{COOKIE_NAME}={dashboard_token()}; HttpOnly; SameSite=Strict; "
f"Path=/; Max-Age={COOKIE_MAX_AGE}")
return self.send_json({"ok": True}, extra_headers={"Set-Cookie": cookie})
def handle_service(self):
"""start / stop / restart pe UNITATEA FIXA. Numele nu vine din request."""
data = self.read_json()
action = str(data.get("action") or "")
if action not in ("start", "stop", "restart"):
return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400)
if action in ("stop", "restart") and not data.get("force"):
busy = inflight_threads(read_state())
if busy:
return self.send_json({
"ok": False,
"error": "tururi in desfasurare",
"inflight": busy,
"hint": "retrimite cu force=true ca sa le intrerupi",
}, 409)
who = tailnet_user(self.headers) or "local"
log_line = f"[actiune] {action} pe {SERVICE}, cerut de {who}"
print(log_line, file=sys.stderr, flush=True)
try:
r = _sysctl(action, SERVICE)
except subprocess.TimeoutExpired:
return self.send_json({"ok": False, "error": "systemctl a depasit timpul"}, 504)
if r.returncode != 0:
return self.send_json({"ok": False, "error": (r.stderr or r.stdout).strip()}, 500)
time.sleep(1.0) # lasa systemd sa actualizeze starea inainte de raspuns
return self.send_json({"ok": True, "action": action, "service": unit_info(SERVICE)})
def handle_decide(self):
data = self.read_json()
rid = str(data.get("request_id") or "")
decision = str(data.get("decision") or "")
if decision not in ("allow", "deny"):
return self.send_json({"ok": False, "error": "decizie invalida"}, 400)
if not rid or "/" in rid or ".." in rid:
return self.send_json({"ok": False, "error": "request_id invalid"}, 400)
import importlib # noqa: PLC0415
approvals = importlib.import_module("security.approvals")
ok = approvals.submit_decision(rid, decision)
return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision},
200 if ok else 404)
def main() -> None:
bind = config.get("DASHBOARD_BIND") or "127.0.0.1"
try:
port = int(config.get("DASHBOARD_PORT") or 18790)
except ValueError:
port = 18790
dashboard_token() # forteaza avertismentul de token la pornire, nu la primul GET
srv = ThreadingHTTPServer((bind, port), Handler)
srv.daemon_threads = True
print(f"dashboard pe http://{bind}:{port} (unitate controlata: {SERVICE})",
file=sys.stderr, flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
if __name__ == "__main__":
main()