diff --git a/CLAUDE.md b/CLAUDE.md index 5ed1f25..a9e3644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ input/ # Oracle DMP files for import - **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md` - **Docker Sandboxes (sbx) — agenți AI izolați**: `proxmox/lxc102-docker/README.md` - **Punte Discord → Claude Code (comandă LXC 171 de pe telefon, dintr-un guild privat)**: `proxmox/lxc171-claude-agent/discord-bridge/README.md` +- **Dashboard de control al punții Discord (stare, restart, orfani, confirmări; tunel SSH pe 18790)**: `proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md` - **Disaster recovery**: `proxmox/vm109-windows-dr/README.md` - **Instalare/migrare Oracle — care director se folosește**: `proxmox/lxc108-oracle/docs/instalare-si-migrare-oracle.md` - **ROA Windows setup scripts (XE/SE 21c)**: `proxmox/lxc108-oracle/roa-windows-setup/README.md` diff --git a/proxmox/lxc171-claude-agent/README.md b/proxmox/lxc171-claude-agent/README.md index 5863dca..6cd2d32 100644 --- a/proxmox/lxc171-claude-agent/README.md +++ b/proxmox/lxc171-claude-agent/README.md @@ -18,6 +18,7 @@ | `scripts/finish-task.sh` | Finalizează task (commit + push) | | `scripts/reap-orphans.sh` | Curăță procese vscode-server orfane + sesiuni zombie (cron, anti-OOM) | | `discord-bridge/` | Punte Discord → Claude Code: comanzi containerul dintr-un guild privat ([README](discord-bridge/README.md)) | +| `discord-bridge/dashboard/` | Dashboard de control al punții: stare, restart, orfani, confirmări ([README](discord-bridge/dashboard/README.md)) | --- diff --git a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md index 600d64e..c61f5fc 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md @@ -9,7 +9,7 @@ ce semnaturi trec granita, ca merge-ul sa fie mecanic. |---|---| | A (nucleu + adaptor) | `session_store.py`, `stream.py`, `runner.py`, `render.py`, `limits.py`, `bot.py`, `config.py`, `tests/**`, `requirements.txt`, `requirements-dev.txt` | | B (securitate) | `security/confirm_hook.py`, `security/infra`, `security/approvals.py`, `security/bot-settings.json.example`, `security/README.md`, `tests/test_confirm_hook.py`, `tests/test_infra.py` | -| C (ops) | `alerts.py`, `cleanup.py`, `ops/claude-discord.service`, `ops/install.sh`, `ops/logrotate.conf`, `tests/test_alerts.py`, `tests/test_cleanup.py`, `README.md`, si liniile de index din `../README.md` + `/workspace/romfastsql/CLAUDE.md` | +| C (ops) | `alerts.py`, `cleanup.py`, `dashboard/**`, `ops/claude-discord.service`, `ops/install.sh`, `ops/logrotate.conf`, `tests/test_alerts.py`, `tests/test_cleanup.py`, `tests/test_dashboard.py`, `README.md`, si liniile de index din `../README.md` + `/workspace/romfastsql/CLAUDE.md` | Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index ffea04d..56e41d6 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -187,12 +187,22 @@ tail -f ~/.claude-discord/logs/alerts.log # ce alerte s-au trimis / au esu systemctl --user restart claude-discord # repornire ``` +Aceleasi lucruri, cu butoane, in **dashboard-ul de control** +([dashboard/README.md](dashboard/README.md)) — stare, restart, orfani, confirmari, +jurnale, la `http://127.0.0.1:18790` (tunel SSH; e legat de localhost intentionat): + +```bash +ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 & +grep DASHBOARD_TOKEN ~/.claude-discord/env +``` + | Fisier | Ce e | |--------|------| | `~/.claude-discord/env` | token + allowlist + limite (0600) | | `~/.claude-discord/state.json` | sesiuni, directoare, pid-uri, cost | | `~/.claude-discord/logs/bot.log` | stdout/stderr al botului (rotit zilnic, 14 zile) | | `~/.claude-discord/logs/alerts.log` | jurnalul alertelor | +| `~/.claude-discord/logs/dashboard.log` | stdout/stderr al dashboard-ului de control | | `~/.claude-discord/alerts-dedup.json` | fereastra de dedup a alertelor | | `~/.claude-discord/approvals/` | cereri de confirmare intre hook si bot | @@ -345,4 +355,7 @@ baza pe ele ca pe o bariera. | `ops/install.sh` | instalare idempotenta | C | | `ops/env.example` | sablon de configurare | C | | `ops/logrotate.conf` | rotatia logurilor | C | +| `dashboard/api.py` | dashboard de control (stare, restart, orfani, confirmari) | C | +| `dashboard/index.html`, `login.html`, `static/` | interfata dashboard-ului | C | +| `dashboard/claude-discord-dashboard.service` | unit systemd pentru dashboard | C | | `INTERFACES.md` | contractul intre module | orchestrator | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md new file mode 100644 index 0000000..178a8d9 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md @@ -0,0 +1,125 @@ +# Dashboard de control pentru puntea Discord (LXC 171) + +Panou web pentru starea si repornirea puntii, modelat dupa dashboard-ul agentului +**echo** de pe LXC 110 (`/home/moltbot/echo-core/dashboard`, port 8088): acelasi tip +de server (stdlib `http.server`, zero dependinte), aceiasi tokeni de design, acelasi +tipar de endpoint-uri ca in `handlers/eco.py`. + +``` +http://127.0.0.1:18790 +``` + +## Ce arata si ce poate face + +| Zona | Continut | +|---|---| +| **Serviciu** | stare `active/running`, uptime, PID, memoria cgroup-ului, numarul de restarturi, firele active, costul zilei fata de plafon | +| **Butoane** | Pornește / Oprește / Repornește puntea, cauta si curata procese orfane (`cleanup.py`), reporneste dashboard-ul insusi | +| **Fire active** | ce e in `state.json`: fir, model, `cwd`, daca procesul `claude` traieste, daca are tur in desfasurare, cost | +| **Diagnostic** | 8 verificari (vezi mai jos), reimprospatate la 30s | +| **Confirmari in asteptare** | cererile hook-ului `PreToolUse` — se pot aproba/refuza direct din pagina, nu doar din Discord | +| **Jurnal** | ultimele 300 de linii din `bot.log` sau `infra.log` | + +Starea se reimprospateaza automat la 5 secunde. + +## Decizii de proiectare + +**O singura unitate controlata.** `/api/service` actioneaza intotdeauna pe +`claude-discord.service`; numele unitatii nu vine niciodata din cerere. Altfel +panoul ar fi un `systemctl` remote fara parola pentru tot ce ruleaza sub `claude`. +Exista un test care trimite `{"unit": "ssh.service"}` si verifica faptul ca tot +puntea e repornita. + +**Bind pe 127.0.0.1.** Butonul de restart opreste un agent care ruleaza cu +`bypassPermissions` si are chei SSH catre tot clusterul. Se ajunge la el prin +tunel SSH (mai jos), nu expus in LAN. `DASHBOARD_BIND` poate schimba asta, dar +atunci tokenul ramane singura bariera. + +**Restart protejat de tururi in zbor.** `stop`/`restart` intorc **409** cu lista +firelor care au un tur in desfasurare; interfata intreaba si retrimite cu +`force: true` doar dupa confirmare. `KillMode=control-group` din unitul puntii +omoara tot cgroup-ul, deci un restart neatent taie raspunsuri pe jumatate scrise. + +**Dashboard-ul e un unit separat** (`claude-discord-dashboard.service`), tocmai ca +o repornire a puntii sa nu ia si panoul din care ai apasat butonul. Invers, +`/api/restart-self` iese cu cod 0 si lasa `Restart=always` sa-l reporneasca. + +**Citire fara lock.** `state.json` e citit direct, fara `flock`: panoul nu are voie +sa blocheze botul. Un JSON prins la mijlocul unei scrieri se ignora si se reia la +urmatorul poll (test: `test_state_corupt_nu_arunca`). + +## Verificarile din Diagnostic + +1. serviciul `claude-discord` (stare + numar de restarturi) +2. `state.json` citibil, cate fire contine +3. costul zilei fata de `COST_CAP_USD_DAY` +4. spatiu liber pe disc (prag 10%) +5. dimensiunea `bot.log` (prag 100 MB) +6. **regulile `deny` din `bot-settings.json`** — pica daca reapare `Bash(ssh:*)` sau + `Bash(scp:*)`. Sunt regulile care pe 2026-08-30 au taiat complet accesul puntii la + infrastructura: `deny` are precedenta peste `bypassPermissions` si opreste turul + inainte de hook (vezi `../security/README.md`). +7. prezenta hook-ului de confirmare +8. CLI-ul `claude` in PATH + +## Endpoint-uri + +Toate cer cookie-ul de sesiune, obtinut cu `POST /api/auth/login`. + +| Metoda | Ruta | Ce face | +|---|---|---| +| GET | `/api/status` | serviciu + dashboard + fire + cost + numar de confirmari | +| GET | `/api/logs?lines=N&file=bot\|infra` | ultimele N linii (plafon 2000) | +| GET | `/api/doctor` | verificarile de mai sus | +| GET | `/api/approvals` | cererile `pending` | +| GET/POST | `/api/cleanup` | GET = doar cauta; POST `{"dry_run": false}` = omoara orfanii | +| POST | `/api/service` | `{"action": "start\|stop\|restart", "force": bool}` | +| POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|deny"}` | +| POST | `/api/restart-self` | reporneste dashboard-ul | +| POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul | + +Autentificarea e un token din `~/.claude-discord/env` schimbat pe un cookie +`HttpOnly; SameSite=Strict` valabil 30 de zile, comparat cu `secrets.compare_digest`. +Fara `DASHBOARD_TOKEN` in env, procesul isi genereaza unul aleator si il scrie in +`logs/dashboard.log` — **nu** ramane deschis. + +## Instalare + +`ops/install.sh` face totul (leaga unitul, genereaza `DASHBOARD_TOKEN` daca lipseste, +porneste serviciul). Manual: + +```bash +ln -sfn /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service \ + ~/.config/systemd/user/claude-discord-dashboard.service +printf 'DASHBOARD_TOKEN=%s\n' "$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')" >> ~/.claude-discord/env +systemctl --user daemon-reload +systemctl --user enable --now claude-discord-dashboard +``` + +Setari optionale in `~/.claude-discord/env`: `DASHBOARD_BIND` (implicit `127.0.0.1`), +`DASHBOARD_PORT` (implicit `18790`). + +## Acces + +Fiind legat de localhost, se ajunge la el prin tunel SSH — la fel ca la dashboard-ul +lui echo: + +```bash +# de pe statia de lucru (direct sau prin Tailscale: 100.95.55.51) +ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 & +# apoi http://localhost:18790 +``` + +Tokenul se citeste cu `grep DASHBOARD_TOKEN ~/.claude-discord/env`. + +## Teste + +```bash +cd proxmox/lxc171-claude-agent/discord-bridge +python3 -m pytest tests/test_dashboard.py -q +``` + +25 de teste, fara retea si fara `systemctl` real (dublura inregistreaza apelurile). +Acopera autentificarea, faptul ca unitatea nu poate fi aleasa din cerere, blocajul pe +tur in zbor si trecerea cu `force`, traversarea de cale in `request_id`, `state.json` +corupt si verificarea de regresie pentru `deny(ssh)`. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py new file mode 100644 index 0000000..fbca525 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -0,0 +1,525 @@ +#!/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() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service new file mode 100644 index 0000000..7830068 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service @@ -0,0 +1,42 @@ +# Unit systemd de UTILIZATOR pentru dashboard-ul de control al puntii Discord. +# +# Se instaleaza in ~/.config/systemd/user/claude-discord-dashboard.service +# (vezi ops/install.sh). Ruleaza ca `claude`, ACELASI utilizator ca puntea — +# de asta poate face `systemctl --user restart claude-discord` fara sudo. +# +# systemctl --user daemon-reload +# systemctl --user enable --now claude-discord-dashboard +# journalctl --user -u claude-discord-dashboard -f + +[Unit] +Description=Dashboard de control pentru puntea Discord (LXC 171 claude-agent) +Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/.claude-discord +EnvironmentFile=%h/.claude-discord/env +Environment=PYTHONUNBUFFERED=1 +# nvm, ca verificarea `CLI claude` din /api/doctor sa vada acelasi PATH ca botul +Environment=PATH=%h/bin:%h/.nvm/versions/node/v20.19.6/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Stdlib only: nu are nevoie de venv-ul botului. +ExecStart=/usr/bin/python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py + +# NU KillMode=control-group aici: dashboard-ul nu are copii de curatat, iar +# `/api/restart-self` se bazeaza pe iesirea curata + Restart=always. +Restart=always +RestartSec=2 + +# Panou de citit stare, nu proces de lucru: plafon strans, ca sa nu concureze +# cu puntea pentru memoria containerului (istoric de OOM, 2026-06-24). +MemoryHigh=192M +MemoryMax=384M + +StandardOutput=append:%h/.claude-discord/logs/dashboard.log +StandardError=append:%h/.claude-discord/logs/dashboard.log +SyslogIdentifier=claude-discord-dashboard + +[Install] +WantedBy=default.target diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html new file mode 100644 index 0000000..c935b74 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -0,0 +1,273 @@ + + + + + +Punte Discord — control + + + + + +
+ + + — + + +
+ +
+ + +
+

Serviciu

+
+
+ + + + + + + +
+
+ +
+ +
+

Fire active

+
+ + + +
FirModelDirectorStareUSD
+
+
+ + +
+

Diagnostic

+
se încarcă…
+
+
+ + +
+

Confirmări în așteptare

+
niciuna
+
+ + +
+

Jurnal + + + + + +

+
se încarcă…
+
+ +
+ +
+ + + + diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html new file mode 100644 index 0000000..cdf5d52 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html @@ -0,0 +1,43 @@ + + + + + +Autentificare — Punte Discord + + + + +
+
+

Punte Discord → Claude Code

+

Token din ~/.claude-discord/env (DASHBOARD_TOKEN).

+ +
+ +
+
+ + + diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css new file mode 100644 index 0000000..199ee25 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css @@ -0,0 +1,258 @@ +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg-base); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); } + +/* ── antet ─────────────────────────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-6); + background: var(--header-bg); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} +.logo { + display: flex; + align-items: center; + gap: var(--space-2); + font-weight: 700; + font-size: var(--text-lg); + color: var(--text-primary); + text-decoration: none; +} +.logo .dot { + width: 10px; height: 10px; + border-radius: 50%; + background: var(--text-muted); + box-shadow: 0 0 0 4px var(--bg-surface); +} +.logo .dot.on { background: var(--success); } +.logo .dot.off { background: var(--error); } +.header .spacer { flex: 1; } +.header .meta { color: var(--text-muted); font-size: var(--text-xs); } + +/* ── structura ─────────────────────────────────────────────────────── */ +.wrap { + max-width: 1100px; + margin: 0 auto; + padding: var(--space-6); + display: grid; + gap: var(--space-6); +} +.grid-2 { + display: grid; + gap: var(--space-6); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-5); +} +.card > h2 { + margin: 0 0 var(--space-4); + font-size: var(--text-base); + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: var(--space-2); +} +.card > h2 .count { + font-weight: 400; + font-size: var(--text-xs); + color: var(--text-muted); +} + +/* ── metrici ───────────────────────────────────────────────────────── */ +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-3); +} +.metric { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); +} +.metric .label { + font-size: var(--text-xs); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.metric .value { + font-size: var(--text-xl); + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +/* ── pastile de stare ──────────────────────────────────────────────── */ +.pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 2px var(--space-3); + border-radius: 999px; + font-size: var(--text-xs); + font-weight: 600; + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-secondary); +} +.pill.ok { color: var(--success); border-color: rgba(34, 197, 94, 0.5); } +.pill.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.5); } +.pill.warn { color: var(--warning); border-color: rgba(234, 179, 8, 0.5); } +.pill.busy { color: var(--accent); border-color: var(--border-focus); } + +/* ── butoane ───────────────────────────────────────────────────────── */ +.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); } +button { + font: inherit; + font-weight: 600; + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-2) var(--space-4); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} +button:hover:not(:disabled) { background: var(--bg-surface-hover); border-color: var(--border-focus); } +button:active:not(:disabled) { background: var(--bg-surface-active); } +button:disabled { opacity: 0.45; cursor: not-allowed; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } +button.danger { color: var(--error); border-color: rgba(239, 68, 68, 0.5); } +button.small { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); } + +/* ── tabele ────────────────────────────────────────────────────────── */ +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } +th { + text-align: left; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + font-weight: 600; + padding: 0 var(--space-3) var(--space-2); + white-space: nowrap; +} +td { + padding: var(--space-3); + border-top: 1px solid var(--border); + vertical-align: middle; +} +td.mono, .mono { font-family: var(--font-mono); font-size: var(--text-xs); } +td.num { font-variant-numeric: tabular-nums; text-align: right; } + +.empty { + color: var(--text-muted); + font-style: italic; + padding: var(--space-4) 0; +} + +/* ── verificari ────────────────────────────────────────────────────── */ +.check { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: var(--space-2) 0; + border-top: 1px solid var(--border); +} +.check:first-child { border-top: 0; } +.check .name { font-weight: 500; color: var(--text-primary); min-width: 200px; } +.check .detail { color: var(--text-muted); font-size: var(--text-xs); } +.check .mark { font-weight: 700; } +.check.pass .mark { color: var(--success); } +.check.fail .mark { color: var(--error); } + +/* ── jurnal ────────────────────────────────────────────────────────── */ +pre.log { + margin: 0; + max-height: 420px; + overflow: auto; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.6; + white-space: pre; + color: var(--text-secondary); +} +:root[data-theme="light"] pre.log { background: rgba(0, 0, 0, 0.04); } + +/* ── notificari ────────────────────────────────────────────────────── */ +#toast { + position: fixed; + right: var(--space-6); + bottom: var(--space-6); + z-index: 100; + display: grid; + gap: var(--space-2); + max-width: 380px; +} +.toast { + background: var(--bg-elevated); + color: var(--text-primary); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + font-size: var(--text-sm); +} +.toast.ok { border-left-color: var(--success); } +.toast.bad { border-left-color: var(--error); } + +/* ── autentificare ─────────────────────────────────────────────────── */ +.login { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-6); +} +.login form { + width: min(380px, 100%); + display: grid; + gap: var(--space-4); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-8); +} +.login h1 { margin: 0; font-size: var(--text-xl); color: var(--text-primary); } +.login p { margin: 0; color: var(--text-muted); font-size: var(--text-xs); } +input[type="password"] { + font: inherit; + font-family: var(--font-mono); + width: 100%; + padding: var(--space-3); + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +input[type="password"]:focus { outline: none; border-color: var(--border-focus); } +.error-msg { color: var(--error); font-size: var(--text-xs); min-height: 1em; } diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/tokens.css b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/tokens.css new file mode 100644 index 0000000..dfb6431 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/tokens.css @@ -0,0 +1,69 @@ +/* Tokenii de design ai dashboard-ului puntii Discord. + Aceleasi valori ca in echo-core/dashboard/DESIGN.md, ca cele doua panouri sa + arate ca facute de aceeasi mana. Tema implicita: intunecata. */ + +:root { + /* suprafete */ + --bg-base: #13131a; + --bg-surface: rgba(255, 255, 255, 0.12); + --bg-surface-hover: rgba(255, 255, 255, 0.16); + --bg-surface-active: rgba(255, 255, 255, 0.20); + --bg-elevated: rgba(255, 255, 255, 0.14); + --header-bg: rgba(19, 19, 26, 0.95); + + /* text */ + --text-primary: #ffffff; + --text-secondary: #f5f5f5; + --text-muted: #b9b9c2; + + /* accent + contur */ + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-subtle: rgba(59, 130, 246, 0.2); + --border: rgba(255, 255, 255, 0.3); + --border-focus: rgba(59, 130, 246, 0.7); + + /* semantice */ + --success: #22c55e; + --warning: #eab308; + --error: #ef4444; + + /* tipografie */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + + /* grila de 8px */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +:root[data-theme="light"] { + --bg-base: #f6f7f9; + --bg-surface: rgba(0, 0, 0, 0.045); + --bg-surface-hover: rgba(0, 0, 0, 0.07); + --bg-surface-active: rgba(0, 0, 0, 0.1); + --bg-elevated: #ffffff; + --header-bg: rgba(246, 247, 249, 0.95); + + --text-primary: #14141a; + --text-secondary: #2c2c36; + --text-muted: #5c5c6b; + + --border: rgba(0, 0, 0, 0.16); + --border-focus: rgba(59, 130, 246, 0.6); +} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example index cdb82f6..b486fdb 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example @@ -52,3 +52,11 @@ ALERT_RECIPIENT=root # face din Discord sa aiba istoric separat de proiectele reale. `/cd ` il # schimba per fir, oriunde in /workspace. DEFAULT_CWD=/workspace/claude-agent + +# --- dashboard de control (dashboard/api.py) -------------------------------- +# Token de acces. Generat automat de ops/install.sh daca lipseste. +DASHBOARD_TOKEN= +# Implicit 127.0.0.1: butoanele opresc un agent cu bypassPermissions si chei SSH +# spre tot clusterul, deci accesul se face prin tunel SSH, nu expus in LAN. +DASHBOARD_BIND=127.0.0.1 +DASHBOARD_PORT=18790 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh index 8b4e44e..5b4cbd3 100755 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh @@ -12,7 +12,7 @@ # 2. env 0600 din ops/env.example (doar daca lipseste) # 3. venv + dependinte din requirements.txt # 4. loginctl enable-linger (serviciul de utilizator trebuie sa supravietuiasca logout-ului) -# 5. symlink unit -> ~/.config/systemd/user/claude-discord.service +# 5. symlink-uri unit -> ~/.config/systemd/user/ (puntea + dashboard-ul de control) # 6. intrare de crontab pentru logrotate # 7. systemd-analyze verify + enable set -uo pipefail @@ -22,6 +22,7 @@ SRC="$(dirname "$HERE")" # .../discord-bridge STATE_DIR="$HOME/.claude-discord" UNIT_DIR="$HOME/.config/systemd/user" UNIT_NAME="claude-discord.service" +DASH_UNIT="claude-discord-dashboard.service" DO_START=0 [ "${1:-}" = "--start" ] && DO_START=1 @@ -95,6 +96,22 @@ ln -sfn "$HERE/$UNIT_NAME" "$UNIT_DIR/$UNIT_NAME" info "unit legat: $UNIT_DIR/$UNIT_NAME -> $HERE/$UNIT_NAME" systemctl --user daemon-reload 2>/dev/null || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)" +# Dashboard-ul de control (dashboard/api.py): unit separat, ca o repornire a +# puntii sa nu ia si panoul din care ai apasat butonul. +ln -sfn "$SRC/dashboard/$DASH_UNIT" "$UNIT_DIR/$DASH_UNIT" +info "unit legat: $UNIT_DIR/$DASH_UNIT -> $SRC/dashboard/$DASH_UNIT" + +# Tokenul dashboard-ului: generat o singura data, niciodata rescris. +if grep -qE '^DASHBOARD_TOKEN=.+' "$STATE_DIR/env" 2>/dev/null; then + info "DASHBOARD_TOKEN exista deja in env" +else + printf 'DASHBOARD_TOKEN=%s\n' "$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')" \ + >> "$STATE_DIR/env" + info "DASHBOARD_TOKEN generat si adaugat in $STATE_DIR/env" +fi + +systemctl --user daemon-reload 2>/dev/null || true + if systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | grep -vE 'Unknown key|^$' | grep -q .; then systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | sed 's/^/ /' warn "systemd-analyze verify a raportat probleme (vezi mai sus)" @@ -132,6 +149,9 @@ fi systemctl --user enable "$UNIT_NAME" >/dev/null 2>&1 \ && info "serviciu enabled (porneste la boot)" \ || warn "enable a esuat" +systemctl --user enable --now "$DASH_UNIT" >/dev/null 2>&1 \ + && info "dashboard enabled + pornit pe 127.0.0.1:${DASHBOARD_PORT:-18790}" \ + || warn "enable pentru $DASH_UNIT a esuat" if [ "$DO_START" -eq 1 ]; then if [ ! -f "$SRC/bot.py" ]; then diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py new file mode 100644 index 0000000..9d1c5ff --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py @@ -0,0 +1,336 @@ +"""Teste pentru dashboard-ul de control (dashboard/api.py). + +Zero retea catre exterior si zero systemctl real: `_sysctl` e inlocuit in fiecare +test cu un dublu care inregistreaza argumentele. Serverul HTTP porneste pe un port +efemer legat de 127.0.0.1, exact cum ruleaza in productie. +""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "dashboard")) + +import config # noqa: E402 + +api = pytest.importorskip("api", reason="dashboard/api.py") + + +# --- ajutoare --------------------------------------------------------------- + +def _cp(stdout: str = "", rc: int = 0, stderr: str = "") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr=stderr) + + +@pytest.fixture() +def systemctl_fals(monkeypatch): + """Inlocuieste systemctl. `apeluri` retine ce s-ar fi executat.""" + apeluri: list[tuple[str, ...]] = [] + props = { + "ActiveState": "active", + "SubState": "running", + "UnitFileState": "enabled", + "MainPID": "4242", + "MemoryCurrent": "1048576", + "NRestarts": "3", + "ActiveEnterTimestampMonotonic": "0", + "ActiveEnterTimestamp": "", + } + + def fake(*args, timeout=30.0): + apeluri.append(tuple(args)) + if args[0] == "show": + return _cp(props.get(args[2], "")) + return _cp("") + + monkeypatch.setattr(api, "_sysctl", fake) + fake.apeluri = apeluri # type: ignore[attr-defined] + fake.props = props # type: ignore[attr-defined] + return fake + + +@pytest.fixture() +def server(state_dir, systemctl_fals, monkeypatch): + """Dashboard-ul pe un port efemer + un client mic cu cookie.""" + (state_dir / "env").write_text("DASHBOARD_TOKEN=secret-de-test\n", encoding="utf-8") + config.reload(state_dir) + api.reset_token_cache() + + srv = ThreadingHTTPServer(("127.0.0.1", 0), api.Handler) + srv.daemon_threads = True + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_address[1]}" + + class Client: + def __init__(self): + self.cookie = "" + + def call(self, path, data=None, method=None): + req = urllib.request.Request( + base + path, + data=json.dumps(data).encode() if data is not None else None, + method=method or ("POST" if data is not None else "GET"), + ) + req.add_header("Content-Type", "application/json") + if self.cookie: + req.add_header("Cookie", self.cookie) + def _json(body: str): + """Rutele /api/ intorc JSON; paginile intorc HTML — nu esuam pe ele.""" + try: + return json.loads(body) if body else {} + except ValueError: + return {"_html": body} + + try: + with urllib.request.urlopen(req, timeout=10) as r: + sc = r.headers.get("Set-Cookie") + if sc: + self.cookie = sc.split(";", 1)[0] + return r.status, _json(r.read().decode()), r + except urllib.error.HTTPError as e: + return e.code, _json(e.read().decode()), e + + def login(self, token="secret-de-test"): + return self.call("/api/auth/login", {"token": token}) + + try: + yield Client() + finally: + srv.shutdown() + srv.server_close() + api.reset_token_cache() + + +def _scrie_stare(state_dir, threads: dict, cost: float = 1.5): + (state_dir / "state.json").write_text(json.dumps({ + "version": 1, + "threads": threads, + "cost": {"day": "2026-08-30", "usd": cost}, + }), encoding="utf-8") + + +# --- autentificare ---------------------------------------------------------- + +def test_api_fara_cookie_da_401(server): + assert server.call("/api/status")[0] == 401 + + +def test_index_fara_cookie_redirectioneaza_la_login(server): + status, data, resp = server.call("/") + # urllib urmareste redirectul singur: ajungem pe pagina de login, nu pe index + assert status == 200 + assert resp.url.endswith("/login.html") + assert "DASHBOARD_TOKEN" in data["_html"] + + +def test_token_gresit_e_refuzat(server): + assert server.login("gresit")[0] == 401 + assert server.call("/api/status")[0] == 401 + + +def test_login_apoi_status(server): + assert server.login()[0] == 200 + status, data, _ = server.call("/api/status") + assert status == 200 + assert data["service"]["unit"] == api.SERVICE + assert data["service"]["restarts"] == 3 + assert data["service"]["memory_bytes"] == 1048576 + + +def test_logout_invalideaza_cookie(server): + server.login() + server.call("/api/auth/logout", {}) + server.cookie = "dashboard=" + assert server.call("/api/status")[0] == 401 + + +def test_token_lipsa_din_env_nu_deschide_dashboardul(state_dir, monkeypatch): + """Fara DASHBOARD_TOKEN se genereaza unul aleator, nu se sare peste auth.""" + (state_dir / "env").write_text("", encoding="utf-8") + config.reload(state_dir) + api.reset_token_cache() + try: + tok = api.dashboard_token() + assert len(tok) >= 20 + assert api.dashboard_token() == tok # stabil in cadrul procesului + finally: + api.reset_token_cache() + + +# --- control de serviciu ---------------------------------------------------- + +def test_actiune_necunoscuta_e_respinsa(server): + server.login() + status, data, _ = server.call("/api/service", {"action": "mask"}) + assert status == 400 + assert not data["ok"] + + +def test_nu_se_poate_alege_unitatea_din_request(server, systemctl_fals): + """Chiar daca cererea cere alt unit, se actioneaza tot pe puntea Discord.""" + server.login() + server.call("/api/service", {"action": "restart", "service": "ssh.service", + "unit": "ssh.service"}) + actiuni = [a for a in systemctl_fals.apeluri if a[0] == "restart"] + assert actiuni == [("restart", api.SERVICE)] + + +def test_restart_blocat_de_tur_in_zbor(server, state_dir, systemctl_fals): + server.login() + _scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}}) + status, data, _ = server.call("/api/service", {"action": "restart"}) + assert status == 409 + assert data["inflight"] == ["111"] + assert not [a for a in systemctl_fals.apeluri if a[0] == "restart"] + + +def test_restart_cu_force_trece_peste_tur(server, state_dir, systemctl_fals): + server.login() + _scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}}) + status, data, _ = server.call("/api/service", {"action": "restart", "force": True}) + assert status == 200 and data["ok"] + assert ("restart", api.SERVICE) in systemctl_fals.apeluri + + +def test_start_nu_cere_force(server, state_dir, systemctl_fals): + """`start` nu poate intrerupe nimic, deci nu are de ce sa fie blocat.""" + server.login() + _scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}}) + assert server.call("/api/service", {"action": "start"})[0] == 200 + + +def test_esecul_systemctl_ajunge_la_client(server, monkeypatch): + server.login() + monkeypatch.setattr(api, "_sysctl", + lambda *a, timeout=30.0: _cp(rc=1, stderr="Unit not found")) + status, data, _ = server.call("/api/service", {"action": "restart"}) + assert status == 500 and "Unit not found" in data["error"] + + +# --- stare, jurnale, diagnostic -------------------------------------------- + +def test_threads_view_marcheaza_tur_in_zbor(state_dir): + _scrie_stare(state_dir, { + "a": {"cwd": "/workspace/x", "model": "sonnet", "cost_usd_total": 2.5, + "last_active": 10, "inflight": {"turn_id": "t"}}, + "b": {"cwd": "/workspace/y", "model": "opus", "last_active": 20}, + }) + view = api.threads_view(api.read_state()) + assert [t["thread_id"] for t in view] == ["b", "a"] # sortare desc dupa activitate + assert view[1]["inflight"] and not view[0]["inflight"] + assert view[1]["cost_usd"] == 2.5 + + +def test_state_corupt_nu_arunca(state_dir): + (state_dir / "state.json").write_text("{ nu e json", encoding="utf-8") + assert api.read_state() == {} + assert api.threads_view(api.read_state()) == [] + + +def test_logs_taie_si_plafoneaza(server, state_dir): + server.login() + (config.LOG_DIR).mkdir(parents=True, exist_ok=True) + api.bot_log().write_text("\n".join(f"linia {i}" for i in range(500)), encoding="utf-8") + _, data, _ = server.call("/api/logs?lines=5") + assert data["lines"] == [f"linia {i}" for i in range(495, 500)] + + +def test_logs_fisier_inexistent(server): + server.login() + _, data, _ = server.call("/api/logs?file=infra") + assert "nu exista" in data["lines"][0] + + +def test_doctor_prinde_deny_ul_care_taie_ssh(server, state_dir): + """Regresia din 2026-08-30: Bash(ssh:*) in deny a taiat accesul la infra.""" + server.login() + (state_dir / "bot-settings.json").write_text(json.dumps( + {"permissions": {"deny": ["Bash(ssh:*)", "Bash(qm destroy:*)"]}}), encoding="utf-8") + _, data, _ = server.call("/api/doctor") + check = next(c for c in data["checks"] if "deny" in c["name"]) + assert not check["pass"] and "Bash(ssh:*)" in check["detail"] + + +def test_doctor_accepta_deny_ul_curatat(server, state_dir): + server.login() + (state_dir / "bot-settings.json").write_text(json.dumps( + {"permissions": {"deny": ["Bash(qm destroy:*)"]}}), encoding="utf-8") + _, data, _ = server.call("/api/doctor") + check = next(c for c in data["checks"] if "deny" in c["name"]) + assert check["pass"] + + +# --- aprobari --------------------------------------------------------------- + +def _cerere(state_dir, rid="abc123", status="pending"): + config.APPROVALS_DIR.mkdir(parents=True, exist_ok=True) + (config.APPROVALS_DIR / f"{rid}.json").write_text(json.dumps({ + "request_id": rid, "thread_id": "111", "tool_name": "Bash", + "command": "rm -rf /var/tmp/x", "rule": "rm_recursiv", + "reason": "stergere recursiva", "created_at": time.time(), + "expires_at": time.time() + 300, "status": status, + }), encoding="utf-8") + + +def test_approvals_arata_doar_pending(server, state_dir): + server.login() + _cerere(state_dir, "aaa", "pending") + _cerere(state_dir, "bbb", "allow") + _, data, _ = server.call("/api/approvals") + assert [a["request_id"] for a in data["approvals"]] == ["aaa"] + assert data["approvals"][0]["expires_in"] > 0 + + +def test_status_numara_aprobarile(server, state_dir): + server.login() + _cerere(state_dir, "aaa") + _, data, _ = server.call("/api/status") + assert data["pending_approvals"] == 1 + + +def test_decizie_invalida_e_respinsa(server): + server.login() + assert server.call("/api/approvals/decide", + {"request_id": "aaa", "decision": "poate"})[0] == 400 + + +def test_request_id_cu_traversare_e_respins(server): + server.login() + assert server.call("/api/approvals/decide", + {"request_id": "../../etc/passwd", "decision": "allow"})[0] == 400 + + +def test_decizia_ajunge_in_fisierul_cererii(server, state_dir): + server.login() + _cerere(state_dir, "aaa") + status, data, _ = server.call("/api/approvals/decide", + {"request_id": "aaa", "decision": "allow"}) + assert status == 200 and data["ok"] + scris = json.loads((config.APPROVALS_DIR / "aaa.json").read_text()) + assert scris["status"] == "allow" + + +def test_cerere_inexistenta_da_404(server): + server.login() + assert server.call("/api/approvals/decide", + {"request_id": "nuexista", "decision": "allow"})[0] == 404 + + +# --- rute ------------------------------------------------------------------- + +def test_ruta_necunoscuta(server): + server.login() + assert server.call("/api/nope")[0] == 404 + assert server.call("/api/nope", {})[0] == 404