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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="spacer"></span>
|
||||
<span class="meta" id="lastPoll">—</span>
|
||||
<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>
|
||||
|
||||
<div class="wrap">
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == ""
|
||||
|
||||
Reference in New Issue
Block a user