From b14e7bc50ad97999c82ef28e0d9e7dc7d88a640e Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sun, 30 Aug 2026 14:16:20 +0000 Subject: [PATCH] feat(discord-bridge): dashboard fara token (DASHBOARD_AUTH=off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7 --- .../discord-bridge/README.md | 4 +- .../discord-bridge/dashboard/README.md | 36 ++++++++++--- .../discord-bridge/dashboard/api.py | 35 ++++++++++++- .../discord-bridge/dashboard/index.html | 7 ++- .../discord-bridge/ops/env.example | 5 ++ .../discord-bridge/tests/test_dashboard.py | 50 +++++++++++++++++++ 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index a3e8ad2..15f3c12 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -192,8 +192,8 @@ Aceleasi lucruri, cu butoane, in **dashboard-ul de control** jurnale: ``` -https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la moltbot) -grep DASHBOARD_TOKEN ~/.claude-discord/env # tokenul de login +https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la moltbot) + # fara login: DASHBOARD_AUTH=off ``` Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md index 2f5d272..cc7819b 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md @@ -82,7 +82,8 @@ urmatorul poll (test: `test_state_corupt_nu_arunca`). ## Endpoint-uri -Toate cer cookie-ul de sesiune, obtinut cu `POST /api/auth/login`. +Toate cer cookie-ul de sesiune, obtinut cu `POST /api/auth/login` — sau nimic, +cand `DASHBOARD_AUTH=off` (vezi mai jos). | Metoda | Ruta | Ce face | |---|---|---| @@ -94,12 +95,34 @@ Toate cer cookie-ul de sesiune, obtinut cu `POST /api/auth/login`. | 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 | +| POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul (inutile cu `DASHBOARD_AUTH=off`) | 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. +`logs/dashboard.log` — **nu** ramane deschis din neatentie. + +### Fara token (`DASHBOARD_AUTH=off`) + +Configuratia curenta pe LXC 171. Nu mai exista login: `/login.html` duce inapoi la +panou, iar butonul „Ieși" 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`; +- `tailscale serve` publica panoul `tainet only`, iar accesul in tailnet e deja + autentificat de Tailscale. + +Ce se pierde: orice dispozitiv din tailnet, si orice proces din container care +ajunge la port, poate opri sau reporni puntea. Ce ramane: fiecare start/stop/restart +se scrie in `logs/dashboard.log` cu identitatea din antetul `Tailscale-User-Login` +pus de `tailscale serve` (`local` cand cererea vine de pe 127.0.0.1). Antetul e +**doar pentru jurnal** — nu decide accesul, fiindca un proces local l-ar putea +fabrica. + +Inapoi la token: scoate `DASHBOARD_AUTH=off` din `~/.claude-discord/env` si +reporneste serviciul. Doar `off`, `none`, `0` si `false` scot login-ul; orice alta +valoare il pastreaza. ## Instalare @@ -116,7 +139,7 @@ systemctl --user enable --now claude-discord-dashboard Setari optionale in `~/.claude-discord/env`: `DASHBOARD_BIND` (implicit `127.0.0.1`), `DASHBOARD_PORT` (implicit `18790`), `DASHBOARD_PREFIX` (implicit gol; `/claude` cand e -publicat prin `tailscale serve --set-path`). +publicat prin `tailscale serve --set-path`), `DASHBOARD_AUTH` (`off` scoate login-ul). ## Acces @@ -160,7 +183,8 @@ 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`. +Cu `DASHBOARD_AUTH=off` (configuratia curenta) nu se cere nimic la intrare. Daca +tokenul e reactivat, se citeste cu `grep DASHBOARD_TOKEN ~/.claude-discord/env`. ## Teste @@ -169,7 +193,7 @@ cd proxmox/lxc171-claude-agent/discord-bridge python3 -m pytest tests/test_dashboard.py -q ``` -35 de teste, fara retea si fara `systemctl` real (dublura inregistreaza apelurile). +41 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, montarea sub prefix (cu si fara slash final) si verificarea de regresie diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index b629f69..3e02e17 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -90,11 +90,24 @@ def reset_token_cache() -> None: _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. + 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: @@ -110,6 +123,13 @@ def dashboard_token() -> str: 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( @@ -391,6 +411,8 @@ class Handler(SimpleHTTPRequestHandler): 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()) @@ -429,6 +451,12 @@ class Handler(SimpleHTTPRequestHandler): 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() @@ -474,6 +502,8 @@ class Handler(SimpleHTTPRequestHandler): "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": @@ -548,6 +578,9 @@ class Handler(SimpleHTTPRequestHandler): "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: diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index a5ef28f..11ceb23 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -14,7 +14,7 @@ — - +
@@ -134,9 +134,12 @@ async function refresh() { if (!res.ok) return; var s = res.data, svcInfo = s.service; + // fara autentificare, butonul "Ieși" nu are ce sa faca + document.getElementById('btnLogout').hidden = (s.auth === false); document.getElementById('unitName').textContent = svcInfo.unit; document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off'); - document.getElementById('lastPoll').textContent = 'actualizat ' + new Date().toLocaleTimeString('ro-RO'); + document.getElementById('lastPoll').textContent = + (s.user ? s.user + ' · ' : '') + 'actualizat ' + new Date().toLocaleTimeString('ro-RO'); var capTxt = s.cost_cap > 0 ? ' / ' + s.cost_cap.toFixed(2) : ''; document.getElementById('metrics').innerHTML = [ diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example index 93e2055..c3ff86c 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example @@ -56,6 +56,11 @@ DEFAULT_CWD=/workspace/claude-agent # --- dashboard de control (dashboard/api.py) -------------------------------- # Token de acces. Generat automat de ops/install.sh daca lipseste. DASHBOARD_TOKEN= +# `off` scoate complet login-ul. De pus DOAR fiindca panoul e legat de 127.0.0.1 +# si publicat exclusiv in tailnet: acolo identitatea o face Tailscale (antetul +# Tailscale-User-Login ajunge in jurnal la fiecare start/stop/restart). Orice +# alta valoare pastreaza tokenul. +DASHBOARD_AUTH= # 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 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py index fe7fe60..7d63864 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py @@ -407,3 +407,53 @@ def test_paginile_nu_se_pun_in_cache(server_cu_prefix): server_cu_prefix.login() _, _, resp = server_cu_prefix.call("/") assert resp.headers.get("Cache-Control") == "no-store" + + +# --- mod fara autentificare (DASHBOARD_AUTH=off) ---------------------------- + +@pytest.fixture() +def server_fara_auth(server, state_dir): + (state_dir / "env").write_text( + "DASHBOARD_TOKEN=secret-de-test\nDASHBOARD_AUTH=off\n", encoding="utf-8") + config.reload(state_dir) + return server + + +def test_fara_auth_apiul_raspunde_fara_cookie(server_fara_auth): + status, data, _ = server_fara_auth.call("/api/status") + assert status == 200 and data["auth"] is False + + +def test_fara_auth_pagina_se_serveste_direct(server_fara_auth): + status, data, resp = server_fara_auth.call("/") + assert status == 200 + assert "Punte Discord" in data["_html"] + assert not resp.url.endswith("login.html") + + +def test_fara_auth_login_html_duce_inapoi_la_panou(server_fara_auth): + _, _, resp = server_fara_auth.call("/login.html") + assert not resp.url.endswith("login.html") + + +def test_fara_auth_se_poate_actiona_pe_serviciu(server_fara_auth, systemctl_fals): + assert server_fara_auth.call("/api/service", {"action": "restart"})[0] == 200 + assert ("restart", api.SERVICE) in systemctl_fals.apeluri + + +def test_valoare_necunoscuta_pentru_auth_nu_deschide_panoul(server, state_dir): + """Doar off/none/0/false scot login-ul; orice altceva il pastreaza.""" + (state_dir / "env").write_text( + "DASHBOARD_TOKEN=secret-de-test\nDASHBOARD_AUTH=da\n", encoding="utf-8") + config.reload(state_dir) + assert not api.auth_disabled() + assert server.call("/api/status")[0] == 401 + + +def test_identitatea_tailscale_e_doar_pentru_jurnal(server_fara_auth): + """Antetul pus de `tailscale serve` se raporteaza, dar nu decide accesul: + pe localhost lipseste, si totusi cererea trece.""" + assert api.tailnet_user({"Tailscale-User-Login": " ana@example.com "}) == "ana@example.com" + assert api.tailnet_user({}) == "" + _, data, _ = server_fara_auth.call("/api/status") + assert data["user"] == ""