Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
Claude Agent 7abefa2b46 feat(discord-bridge): dashboard de control si restart, dupa modelul agentului echo
Panou web pe 127.0.0.1:18790, unit systemd separat de al puntii. Server stdlib
(fara dependinte noi), tokenii de design si tiparul de endpoint-uri preluate din
/home/moltbot/echo-core/dashboard (handlers/eco.py) de pe LXC 110.

Arata: starea unitatii (uptime, PID, memoria cgroup, restarturi), firele din
state.json cu tur in zbor si cost, costul zilei fata de plafon, confirmarile
PreToolUse in asteptare (aprobabile direct din pagina), bot.log / infra.log si
opt verificari de diagnostic.

Face: start / stop / restart pe punte, cautarea si curatarea orfanilor prin
cleanup.py, repornirea propriului serviciu.

Garantii, cu teste:
- unitatea controlata e fixa in cod; un {"unit": "ssh.service"} in cerere nu
  schimba nimic, altfel panoul ar fi systemctl remote fara parola;
- stop/restart intorc 409 cu lista firelor active si cer force explicit, fiindca
  KillMode=control-group taie tururile in desfasurare;
- state.json se citeste fara lock: panoul nu are voie sa blocheze botul;
- diagnosticul pica daca reapare Bash(ssh:*) in deny (regresia de azi).

Uptime-ul se calculeaza din time.monotonic(), nu din /proc/uptime: in LXC acela
e virtualizat de lxcfs si da diferenta negativa fata de monotonic-ul systemd.

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

526 lines
20 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
_TOKEN: str | None = None
def reset_token_cache() -> None:
"""Uita tokenul memorat (folosit de teste dupa `config.reload`)."""
global _TOKEN
_TOKEN = None
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.
"""
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
# ── 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:
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):
path = urlparse(self.path).path
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():
self.send_response(302)
self.send_header("Location", "/login.html")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path == "/":
self.path = "/index.html"
return super().do_GET()
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()),
"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 = 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)
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()