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:
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user