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
This commit is contained in:
Claude Agent
2026-08-30 14:16:20 +00:00
parent e203ee021d
commit b14e7bc50a
6 changed files with 126 additions and 11 deletions

View File

@@ -193,7 +193,7 @@ jurnale:
``` ```
https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la moltbot) https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la moltbot)
grep DASHBOARD_TOKEN ~/.claude-discord/env # tokenul de login # fara login: DASHBOARD_AUTH=off
``` ```
Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`. Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`.

View File

@@ -82,7 +82,8 @@ urmatorul poll (test: `test_state_corupt_nu_arunca`).
## Endpoint-uri ## 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 | | 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/service` | `{"action": "start\|stop\|restart", "force": bool}` |
| POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|deny"}` | | POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|deny"}` |
| POST | `/api/restart-self` | reporneste dashboard-ul | | 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 Autentificarea e un token din `~/.claude-discord/env` schimbat pe un cookie
`HttpOnly; SameSite=Strict` valabil 30 de zile, comparat cu `secrets.compare_digest`. `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 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 ## 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`), Setari optionale in `~/.claude-discord/env`: `DASHBOARD_BIND` (implicit `127.0.0.1`),
`DASHBOARD_PORT` (implicit `18790`), `DASHBOARD_PREFIX` (implicit gol; `/claude` cand e `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 ## Acces
@@ -160,7 +183,8 @@ ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 &
# apoi http://localhost:18790 # 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 ## Teste
@@ -169,7 +193,7 @@ cd proxmox/lxc171-claude-agent/discord-bridge
python3 -m pytest tests/test_dashboard.py -q 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 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` 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 corupt, montarea sub prefix (cu si fara slash final) si verificarea de regresie

View File

@@ -90,11 +90,24 @@ def reset_token_cache() -> None:
_TOKEN = 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: def dashboard_token() -> str:
"""Tokenul de acces, din `~/.claude-discord/env` (`DASHBOARD_TOKEN`). """Tokenul de acces, din `~/.claude-discord/env` (`DASHBOARD_TOKEN`).
Lipsa lui NU deschide dashboard-ul: se genereaza unul aleator per proces si se 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 global _TOKEN
if _TOKEN is None: if _TOKEN is None:
@@ -110,6 +123,13 @@ def dashboard_token() -> str:
return _TOKEN 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 ───────────────────────────────────────────────────────────── # ── systemd ─────────────────────────────────────────────────────────────
def _sysctl(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess: def _sysctl(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess:
return subprocess.run( return subprocess.run(
@@ -391,6 +411,8 @@ class Handler(SimpleHTTPRequestHandler):
return {} return {}
def authed(self) -> bool: def authed(self) -> bool:
if auth_disabled():
return True
got = _parse_cookies(self.headers.get("Cookie", "")).get(COOKIE_NAME, "") got = _parse_cookies(self.headers.get("Cookie", "")).get(COOKIE_NAME, "")
return bool(got) and secrets.compare_digest(got, dashboard_token()) return bool(got) and secrets.compare_digest(got, dashboard_token())
@@ -429,6 +451,12 @@ class Handler(SimpleHTTPRequestHandler):
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
return self.send_html("index.html") return self.send_html("index.html")
if path == "/login.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") return self.send_html("login.html")
self.path = path self.path = path
return super().do_GET() return super().do_GET()
@@ -474,6 +502,8 @@ class Handler(SimpleHTTPRequestHandler):
"cost": state.get("cost") or {}, "cost": state.get("cost") or {},
"cost_cap": float(config.get("COST_CAP_USD_DAY") or 0), "cost_cap": float(config.get("COST_CAP_USD_DAY") or 0),
"pending_approvals": len(pending_approvals()), "pending_approvals": len(pending_approvals()),
"auth": not auth_disabled(),
"user": tailnet_user(self.headers),
"now": time.time(), "now": time.time(),
}) })
if path == "/api/logs": if path == "/api/logs":
@@ -548,6 +578,9 @@ class Handler(SimpleHTTPRequestHandler):
"hint": "retrimite cu force=true ca sa le intrerupi", "hint": "retrimite cu force=true ca sa le intrerupi",
}, 409) }, 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: try:
r = _sysctl(action, SERVICE) r = _sysctl(action, SERVICE)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:

View File

@@ -14,7 +14,7 @@
<span class="spacer"></span> <span class="spacer"></span>
<span class="meta" id="lastPoll">—</span> <span class="meta" id="lastPoll">—</span>
<button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button> <button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button>
<button class="small" onclick="logout()">Ieși</button> <button class="small" id="btnLogout" onclick="logout()" hidden>Ieși</button>
</header> </header>
<div class="wrap"> <div class="wrap">
@@ -134,9 +134,12 @@ async function refresh() {
if (!res.ok) return; if (!res.ok) return;
var s = res.data, svcInfo = s.service; 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('unitName').textContent = svcInfo.unit;
document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off'); 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) : ''; var capTxt = s.cost_cap > 0 ? ' / ' + s.cost_cap.toFixed(2) : '';
document.getElementById('metrics').innerHTML = [ document.getElementById('metrics').innerHTML = [

View File

@@ -56,6 +56,11 @@ DEFAULT_CWD=/workspace/claude-agent
# --- dashboard de control (dashboard/api.py) -------------------------------- # --- dashboard de control (dashboard/api.py) --------------------------------
# Token de acces. Generat automat de ops/install.sh daca lipseste. # Token de acces. Generat automat de ops/install.sh daca lipseste.
DASHBOARD_TOKEN= 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 # 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. # spre tot clusterul, deci accesul se face prin tunel SSH, nu expus in LAN.
DASHBOARD_BIND=127.0.0.1 DASHBOARD_BIND=127.0.0.1

View File

@@ -407,3 +407,53 @@ def test_paginile_nu_se_pun_in_cache(server_cu_prefix):
server_cu_prefix.login() server_cu_prefix.login()
_, _, resp = server_cu_prefix.call("/") _, _, resp = server_cu_prefix.call("/")
assert resp.headers.get("Cache-Control") == "no-store" 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"] == ""