feat(discord-bridge): dashboard publicat in tailnet prin tailscale serve

https://claude-agent.tailf7372d.ts.net/punte — acelasi tipar ca /echo de pe
moltbot: procesul ramane legat de 127.0.0.1, tailscaled il proxeaza si pune HTTPS.

Montarea sub prefix a cerut doua schimbari:

- toate URL-urile din pagini sunt acum relative, fiindca --set-path TAIE prefixul
  inainte de a proxa (serverul vede /api/status, browserul cere /punte/api/status).
  DASHBOARD_PREFIX ramane necesar doar pentru redirectul de login, si e acceptat
  si intact pe intrare, ca sa mearga si curl direct pe localhost.
- adresa fara slash final (/punte) primeste 301 catre /punte/: altfel URL-urile
  relative s-ar rezolva la radacina hostului, unde proxy-ul nu trimite nimic
  incoace, si panoul ar arata gol fara nicio eroare vizibila.

ops/install.sh configureaza serve-ul daca sudo permite; altfel spune comanda.
Sase teste noi pentru montarea sub prefix (31 in total 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 13:27:59 +00:00
parent 57867672f2
commit dd7fa28fa5
9 changed files with 173 additions and 34 deletions

View File

@@ -47,7 +47,7 @@ input/ # Oracle DMP files for import
- **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md` - **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md`
- **Docker Sandboxes (sbx) — agenți AI izolați**: `proxmox/lxc102-docker/README.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` - **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` - **Dashboard de control al punții Discord (stare, restart, orfani, confirmări; https://claude-agent.tailf7372d.ts.net/punte)**: `proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md`
- **Disaster recovery**: `proxmox/vm109-windows-dr/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` - **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` - **ROA Windows setup scripts (XE/SE 21c)**: `proxmox/lxc108-oracle/roa-windows-setup/README.md`

View File

@@ -189,12 +189,15 @@ systemctl --user restart claude-discord # repornire
Aceleasi lucruri, cu butoane, in **dashboard-ul de control** Aceleasi lucruri, cu butoane, in **dashboard-ul de control**
([dashboard/README.md](dashboard/README.md)) — stare, restart, orfani, confirmari, ([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): jurnale:
```bash
ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 &
grep DASHBOARD_TOKEN ~/.claude-discord/env
``` ```
https://claude-agent.tailf7372d.ts.net/punte # din tailnet (ca /echo la moltbot)
grep DASHBOARD_TOKEN ~/.claude-discord/env # tokenul de login
```
Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`.
Fara Tailscale: `ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171`.
| Fisier | Ce e | | Fisier | Ce e |
|--------|------| |--------|------|

View File

@@ -6,7 +6,8 @@ de server (stdlib `http.server`, zero dependinte), aceiasi tokeni de design, ace
tipar de endpoint-uri ca in `handlers/eco.py`. tipar de endpoint-uri ca in `handlers/eco.py`.
``` ```
http://127.0.0.1:18790 https://claude-agent.tailf7372d.ts.net/punte <- din tailnet, ca /echo la moltbot
http://127.0.0.1:18790 <- local / prin tunel SSH
``` ```
## Ce arata si ce poate face ## Ce arata si ce poate face
@@ -97,15 +98,43 @@ 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_PORT` (implicit `18790`), `DASHBOARD_PREFIX` (implicit gol; `/punte` cand e
publicat prin `tailscale serve --set-path`).
## Acces ## Acces
Fiind legat de localhost, se ajunge la el prin tunel SSH — la fel ca la dashboard-ul ### Prin Tailscale (recomandat)
lui echo:
Exact tiparul de la echo (`https://moltbot.tailf7372d.ts.net/echo/`): procesul ramane
legat de `127.0.0.1`, iar `tailscaled` e singurul care ajunge la el si il publica in
tailnet, cu HTTPS si certificat de la Tailscale.
```bash
sudo tailscale serve --bg --set-path /punte http://127.0.0.1:18790
tailscale serve status
```
```
https://claude-agent.tailf7372d.ts.net/punte
```
Vizibil doar in tailnet (`tailnet only`) — nu e `funnel`, deci nu iese in internet.
Configuratia e persistata de `tailscaled`, deci supravietuieste repornirilor.
**Montarea sub prefix**, cele doua capcane si cum sunt rezolvate:
- `tailscale serve --set-path` **taie** prefixul inainte de a proxa, deci serverul
vede `/api/status`, nu `/punte/api/status`. Toate URL-urile din pagini sunt
**relative**, deci merg la orice prefix, fara sa stie de el. `DASHBOARD_PREFIX`
e nevoie doar pentru redirecturi si e acceptat si intact pe intrare (`curl`
direct pe localhost cu `/punte/...` functioneaza).
- adresa **fara slash final** (`/punte`) ar rezolva `api/status` la radacina
hostului, unde proxy-ul nu mai trimite nimic incoace; de aceea forma fara slash
primeste un 301 catre `/punte/`.
### Prin tunel SSH
```bash ```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 & ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 &
# apoi http://localhost:18790 # apoi http://localhost:18790
``` ```
@@ -119,7 +148,8 @@ cd proxmox/lxc171-claude-agent/discord-bridge
python3 -m pytest tests/test_dashboard.py -q python3 -m pytest tests/test_dashboard.py -q
``` ```
25 de teste, fara retea si fara `systemctl` real (dublura inregistreaza apelurile). 31 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 si verificarea de regresie pentru `deny(ssh)`. corupt, montarea sub prefix (cu si fara slash final) si verificarea de regresie
pentru `deny(ssh)`.

View File

@@ -60,6 +60,27 @@ def infra_log() -> Path:
COOKIE_NAME = "dashboard" COOKIE_NAME = "dashboard"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 COOKIE_MAX_AGE = 60 * 60 * 24 * 30
def mount_prefix() -> str:
"""Prefixul sub care e montat panoul (`DASHBOARD_PREFIX`, ex. `/punte`).
`tailscale serve --set-path /punte` TAIE prefixul inainte de a proxa, deci in
mod normal aici ajunge `/api/status`. Prefixul e acceptat totusi si intact,
pentru cazul unui proxy care nu taie si pentru `curl` direct pe localhost.
Paginile nu depind de el: toate URL-urile din HTML sunt relative.
"""
pfx = (config.get("DASHBOARD_PREFIX") or "").strip().rstrip("/")
if pfx and not pfx.startswith("/"):
pfx = "/" + pfx
return pfx
def strip_prefix(path: str) -> str:
pfx = mount_prefix()
if pfx and (path == pfx or path.startswith(pfx + "/")):
return path[len(pfx):] or "/"
return path
_TOKEN: str | None = None _TOKEN: str | None = None
@@ -378,19 +399,34 @@ class Handler(SimpleHTTPRequestHandler):
# --- GET ----------------------------------------------------------- # --- GET -----------------------------------------------------------
def do_GET(self): def do_GET(self):
path = urlparse(self.path).path raw = urlparse(self.path).path
path = strip_prefix(raw)
if path == "/" and not raw.endswith("/"):
# `/punte` fara slash final: URL-urile relative din pagina s-ar
# rezolva la radacina hostului (`/api/status`), unde proxy-ul nu mai
# trimite nimic incoace. Fortam forma cu slash.
self.send_response(301)
self.send_header("Location", raw + "/")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path.startswith("/api/"): if path.startswith("/api/"):
if not self.authed(): if not self.authed():
return self.deny() return self.deny()
return self.route_get(path) return self.route_get(path)
if path in ("/", "/index.html") and not self.authed(): if path in ("/", "/index.html") and not self.authed():
# Prefixul reintra AICI in mod deliberat. Un "/login.html" absolut ar
# arunca browserul in radacina hostului (alt serviciu), iar un
# "login.html" relativ se rezolva gresit cand adresa vine fara slash
# final (`/punte` -> `/login.html`). Cu prefixul reatasat, ambele
# forme ajung unde trebuie, si direct pe localhost la fel: calea de
# intrare e curatata oricum de `strip_prefix`.
self.send_response(302) self.send_response(302)
self.send_header("Location", "/login.html") self.send_header("Location", mount_prefix() + "/login.html")
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
return return
if path == "/": self.path = "/index.html" if path == "/" else path
self.path = "/index.html"
return super().do_GET() return super().do_GET()
def route_get(self, path: str): def route_get(self, path: str):
@@ -428,7 +464,7 @@ class Handler(SimpleHTTPRequestHandler):
# --- POST ---------------------------------------------------------- # --- POST ----------------------------------------------------------
def do_POST(self): def do_POST(self):
path = urlparse(self.path).path path = strip_prefix(urlparse(self.path).path)
if path == "/api/auth/login": if path == "/api/auth/login":
return self.handle_login() return self.handle_login()
if path == "/api/auth/logout": if path == "/api/auth/logout":

View File

@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Punte Discord — control</title> <title>Punte Discord — control</title>
<link rel="stylesheet" href="/static/tokens.css"> <link rel="stylesheet" href="static/tokens.css">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="static/app.css">
</head> </head>
<body> <body>
@@ -102,7 +102,7 @@ function toast(msg, kind) {
} }
async function api(path, opts) { async function api(path, opts) {
var r = await fetch(path, opts || {}); var r = await fetch(path, opts || {});
if (r.status === 401) { location.href = '/login.html'; throw new Error('neautentificat'); } if (r.status === 401) { location.href = 'login.html'; throw new Error('neautentificat'); }
var data = null; var data = null;
try { data = await r.json(); } catch (e) { data = {}; } try { data = await r.json(); } catch (e) { data = {}; }
return { ok: r.ok, status: r.status, data: data }; return { ok: r.ok, status: r.status, data: data };
@@ -130,7 +130,7 @@ function mb(bytes) {
// ── stare ───────────────────────────────────────────────────── // ── stare ─────────────────────────────────────────────────────
var busy = false; var busy = false;
async function refresh() { async function refresh() {
var res = await api('/api/status'); var res = await api('api/status');
if (!res.ok) return; if (!res.ok) return;
var s = res.data, svcInfo = s.service; var s = res.data, svcInfo = s.service;
@@ -173,7 +173,7 @@ async function refresh() {
} }
async function refreshApprovals() { async function refreshApprovals() {
var res = await api('/api/approvals'); var res = await api('api/approvals');
if (!res.ok) return; if (!res.ok) return;
var list = res.data.approvals || []; var list = res.data.approvals || [];
document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) { document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) {
@@ -188,13 +188,13 @@ async function refreshApprovals() {
} }
async function decide(id, d) { async function decide(id, d) {
var res = await post('/api/approvals/decide', { request_id: id, decision: d }); var res = await post('api/approvals/decide', { request_id: id, decision: d });
toast(res.ok ? 'Trimis: ' + d : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad'); toast(res.ok ? 'Trimis: ' + d : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
refresh(); refresh();
} }
async function refreshDoctor() { async function refreshDoctor() {
var res = await api('/api/doctor'); var res = await api('api/doctor');
if (!res.ok) return; if (!res.ok) return;
document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) { document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) {
return '<div class="check ' + (c.pass ? 'pass' : 'fail') + '"><span class="mark">' + return '<div class="check ' + (c.pass ? 'pass' : 'fail') + '"><span class="mark">' +
@@ -210,7 +210,7 @@ async function svc(action, force) {
!confirm('Sigur „' + action + '” pe serviciul punții?')) return; !confirm('Sigur „' + action + '” pe serviciul punții?')) return;
busy = true; busy = true;
try { try {
var res = await post('/api/service', { action: action, force: !!force }); var res = await post('api/service', { action: action, force: !!force });
if (res.status === 409) { if (res.status === 409) {
var n = (res.data.inflight || []).length; var n = (res.data.inflight || []).length;
if (confirm(n + ' fir(e) au tur în desfășurare. Le întrerupi?')) { if (confirm(n + ' fir(e) au tur în desfășurare. Le întrerupi?')) {
@@ -232,7 +232,7 @@ async function svc(action, force) {
async function cleanup(dry) { async function cleanup(dry) {
if (!dry && !confirm('Omor procesele orfane găsite?')) return; if (!dry && !confirm('Omor procesele orfane găsite?')) return;
var res = await post('/api/cleanup', { dry_run: dry }); var res = await post('api/cleanup', { dry_run: dry });
if (!res.ok) { toast('Eșuat: ' + (res.data.error || res.status), 'bad'); return; } if (!res.ok) { toast('Eșuat: ' + (res.data.error || res.status), 'bad'); return; }
var n = (res.data.orphans || []).length; var n = (res.data.orphans || []).length;
toast(dry ? (n + ' orfan(i) găsiți') : (n + ' orfan(i) tratați'), n ? 'bad' : 'ok'); toast(dry ? (n + ' orfan(i) găsiți') : (n + ' orfan(i) tratați'), n ? 'bad' : 'ok');
@@ -241,14 +241,14 @@ async function cleanup(dry) {
async function restartSelf() { async function restartSelf() {
if (!confirm('Repornesc dashboard-ul? Pagina se reîncarcă în câteva secunde.')) return; if (!confirm('Repornesc dashboard-ul? Pagina se reîncarcă în câteva secunde.')) return;
await post('/api/restart-self', {}); await post('api/restart-self', {});
toast('Dashboard-ul repornește…', ''); toast('Dashboard-ul repornește…', '');
setTimeout(function () { location.reload(); }, 4000); setTimeout(function () { location.reload(); }, 4000);
} }
async function logout() { async function logout() {
await post('/api/auth/logout', {}); await post('api/auth/logout', {});
location.href = '/login.html'; location.href = 'login.html';
} }
// ── jurnal ──────────────────────────────────────────────────── // ── jurnal ────────────────────────────────────────────────────
@@ -257,7 +257,7 @@ function setLog(which) { logFile = which; refreshLogs(); }
async function refreshLogs() { async function refreshLogs() {
document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' primary' : ''); document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' primary' : '');
document.getElementById('tabInfra').className = 'small' + (logFile === 'infra' ? ' primary' : ''); document.getElementById('tabInfra').className = 'small' + (logFile === 'infra' ? ' primary' : '');
var res = await api('/api/logs?lines=300&file=' + logFile); var res = await api('api/logs?lines=300&file=' + logFile);
if (!res.ok) return; if (!res.ok) return;
var el = document.getElementById('log'); var el = document.getElementById('log');
el.textContent = (res.data.lines || []).join('\n') || '(gol)'; el.textContent = (res.data.lines || []).join('\n') || '(gol)';

View File

@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Autentificare — Punte Discord</title> <title>Autentificare — Punte Discord</title>
<link rel="stylesheet" href="/static/tokens.css"> <link rel="stylesheet" href="static/tokens.css">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="static/app.css">
</head> </head>
<body> <body>
<div class="login"> <div class="login">
@@ -27,12 +27,12 @@ document.getElementById('f').addEventListener('submit', async function (e) {
var err = document.getElementById('err'); var err = document.getElementById('err');
err.textContent = ''; err.textContent = '';
try { try {
var r = await fetch('/api/auth/login', { var r = await fetch('api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: document.getElementById('token').value }) body: JSON.stringify({ token: document.getElementById('token').value })
}); });
if (r.ok) { location.href = '/'; return; } if (r.ok) { location.href = './'; return; }
err.textContent = 'Token invalid.'; err.textContent = 'Token invalid.';
} catch (ex) { } catch (ex) {
err.textContent = 'Serverul nu răspunde.'; err.textContent = 'Serverul nu răspunde.';

View File

@@ -60,3 +60,7 @@ DASHBOARD_TOKEN=
# 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
DASHBOARD_PORT=18790 DASHBOARD_PORT=18790
# Prefixul sub care il publica `tailscale serve --set-path` (vezi dashboard/README.md).
# Gol = montat in radacina. Paginile au URL-uri relative, deci prefixul e nevoie
# doar pentru redirecturi.
DASHBOARD_PREFIX=/punte

View File

@@ -137,6 +137,25 @@ else
rm -f "$TMP_CRON" rm -f "$TMP_CRON"
fi fi
# --- 6b. tailscale serve pentru dashboard ----------------------------------
# Publica panoul in tailnet la https://<host>.<tailnet>.ts.net/punte, exact ca
# dashboard-ul lui echo de pe LXC 110 (/echo). Serviciul ramane legat de
# 127.0.0.1: tailscaled e singurul care ajunge la el.
DASH_PREFIX="$(grep -E '^DASHBOARD_PREFIX=' "$STATE_DIR/env" 2>/dev/null | cut -d= -f2- || true)"
DASH_PREFIX="${DASH_PREFIX:-/punte}"
DASH_PORT="$(grep -E '^DASHBOARD_PORT=' "$STATE_DIR/env" 2>/dev/null | cut -d= -f2- || true)"
DASH_PORT="${DASH_PORT:-18790}"
if ! command -v tailscale >/dev/null 2>&1; then
warn "tailscale lipseste — dashboard-ul ramane doar pe 127.0.0.1:$DASH_PORT (tunel SSH)"
elif tailscale serve status 2>/dev/null | grep -q "$DASH_PREFIX proxy"; then
info "tailscale serve: $DASH_PREFIX exista deja"
elif sudo -n tailscale serve --bg --set-path "$DASH_PREFIX" "http://127.0.0.1:$DASH_PORT" >/dev/null 2>&1; then
info "tailscale serve: https://$(tailscale status --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')$DASH_PREFIX"
else
warn "nu am putut configura tailscale serve; ruleaza manual:"
warn " sudo tailscale serve --bg --set-path $DASH_PREFIX http://127.0.0.1:$DASH_PORT"
fi
# --- 7. mail --------------------------------------------------------------- # --- 7. mail ---------------------------------------------------------------
if command -v mail >/dev/null 2>&1; then if command -v mail >/dev/null 2>&1; then
info "binarul mail: $(command -v mail)" info "binarul mail: $(command -v mail)"

View File

@@ -334,3 +334,50 @@ def test_ruta_necunoscuta(server):
server.login() server.login()
assert server.call("/api/nope")[0] == 404 assert server.call("/api/nope")[0] == 404
assert server.call("/api/nope", {})[0] == 404 assert server.call("/api/nope", {})[0] == 404
# --- montare sub prefix (tailscale serve --set-path) ------------------------
@pytest.fixture()
def server_cu_prefix(server, state_dir):
"""Acelasi server, dar cu DASHBOARD_PREFIX=/punte in env."""
(state_dir / "env").write_text(
"DASHBOARD_TOKEN=secret-de-test\nDASHBOARD_PREFIX=/punte\n", encoding="utf-8")
config.reload(state_dir)
return server
def test_prefixul_e_normalizat():
assert api.strip_prefix("/api/status") == "/api/status"
def test_rutele_merg_si_cu_prefix_si_fara(server_cu_prefix):
"""Proxy-ul taie prefixul, dar `curl` direct pe localhost nu — merg ambele."""
server_cu_prefix.login()
assert server_cu_prefix.call("/api/status")[0] == 200
assert server_cu_prefix.call("/punte/api/status")[0] == 200
def test_prefixul_gol_nu_taie_nimic(server):
server.login()
assert server.call("/api/status")[0] == 200
assert server.call("/punte/api/status")[0] == 404
def test_redirect_de_login_pastreaza_prefixul(server_cu_prefix):
"""Un `/login.html` absolut ar arunca browserul in radacina hostului."""
status, _, resp = server_cu_prefix.call("/punte/")
assert status == 200
assert resp.url.endswith("/punte/login.html")
def test_calea_fara_slash_final_e_redirectionata(server_cu_prefix):
"""`/punte` fara slash ar rezolva `api/status` la radacina hostului."""
_, _, resp = server_cu_prefix.call("/punte")
assert resp.url.endswith("/punte/login.html")
def test_static_servit_si_sub_prefix(server_cu_prefix):
server_cu_prefix.login()
status, data, _ = server_cu_prefix.call("/punte/static/app.css")
assert status == 200 and ".card" in data["_html"]