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:
@@ -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`.
|
||||
|
||||
```
|
||||
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
|
||||
@@ -97,15 +98,43 @@ 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_PORT` (implicit `18790`), `DASHBOARD_PREFIX` (implicit gol; `/punte` cand e
|
||||
publicat prin `tailscale serve --set-path`).
|
||||
|
||||
## Acces
|
||||
|
||||
Fiind legat de localhost, se ajunge la el prin tunel SSH — la fel ca la dashboard-ul
|
||||
lui echo:
|
||||
### Prin Tailscale (recomandat)
|
||||
|
||||
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
|
||||
# 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 &
|
||||
# apoi http://localhost:18790
|
||||
```
|
||||
@@ -119,7 +148,8 @@ cd proxmox/lxc171-claude-agent/discord-bridge
|
||||
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
|
||||
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)`.
|
||||
|
||||
@@ -60,6 +60,27 @@ def infra_log() -> Path:
|
||||
COOKIE_NAME = "dashboard"
|
||||
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
|
||||
|
||||
|
||||
@@ -378,19 +399,34 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
# --- GET -----------------------------------------------------------
|
||||
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 not self.authed():
|
||||
return self.deny()
|
||||
return self.route_get(path)
|
||||
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_header("Location", "/login.html")
|
||||
self.send_header("Location", mount_prefix() + "/login.html")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
if path == "/":
|
||||
self.path = "/index.html"
|
||||
self.path = "/index.html" if path == "/" else path
|
||||
return super().do_GET()
|
||||
|
||||
def route_get(self, path: str):
|
||||
@@ -428,7 +464,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
# --- POST ----------------------------------------------------------
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
path = strip_prefix(urlparse(self.path).path)
|
||||
if path == "/api/auth/login":
|
||||
return self.handle_login()
|
||||
if path == "/api/auth/logout":
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Punte Discord — control</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="static/tokens.css">
|
||||
<link rel="stylesheet" href="static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -102,7 +102,7 @@ function toast(msg, kind) {
|
||||
}
|
||||
async function api(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;
|
||||
try { data = await r.json(); } catch (e) { data = {}; }
|
||||
return { ok: r.ok, status: r.status, data: data };
|
||||
@@ -130,7 +130,7 @@ function mb(bytes) {
|
||||
// ── stare ─────────────────────────────────────────────────────
|
||||
var busy = false;
|
||||
async function refresh() {
|
||||
var res = await api('/api/status');
|
||||
var res = await api('api/status');
|
||||
if (!res.ok) return;
|
||||
var s = res.data, svcInfo = s.service;
|
||||
|
||||
@@ -173,7 +173,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
var res = await api('/api/approvals');
|
||||
var res = await api('api/approvals');
|
||||
if (!res.ok) return;
|
||||
var list = res.data.approvals || [];
|
||||
document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) {
|
||||
@@ -188,13 +188,13 @@ async function refreshApprovals() {
|
||||
}
|
||||
|
||||
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');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function refreshDoctor() {
|
||||
var res = await api('/api/doctor');
|
||||
var res = await api('api/doctor');
|
||||
if (!res.ok) return;
|
||||
document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) {
|
||||
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;
|
||||
busy = true;
|
||||
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) {
|
||||
var n = (res.data.inflight || []).length;
|
||||
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) {
|
||||
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; }
|
||||
var n = (res.data.orphans || []).length;
|
||||
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() {
|
||||
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…', '');
|
||||
setTimeout(function () { location.reload(); }, 4000);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await post('/api/auth/logout', {});
|
||||
location.href = '/login.html';
|
||||
await post('api/auth/logout', {});
|
||||
location.href = 'login.html';
|
||||
}
|
||||
|
||||
// ── jurnal ────────────────────────────────────────────────────
|
||||
@@ -257,7 +257,7 @@ function setLog(which) { logFile = which; refreshLogs(); }
|
||||
async function refreshLogs() {
|
||||
document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' 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;
|
||||
var el = document.getElementById('log');
|
||||
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Autentificare — Punte Discord</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="static/tokens.css">
|
||||
<link rel="stylesheet" href="static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
@@ -27,12 +27,12 @@ document.getElementById('f').addEventListener('submit', async function (e) {
|
||||
var err = document.getElementById('err');
|
||||
err.textContent = '';
|
||||
try {
|
||||
var r = await fetch('/api/auth/login', {
|
||||
var r = await fetch('api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: document.getElementById('token').value })
|
||||
});
|
||||
if (r.ok) { location.href = '/'; return; }
|
||||
if (r.ok) { location.href = './'; return; }
|
||||
err.textContent = 'Token invalid.';
|
||||
} catch (ex) {
|
||||
err.textContent = 'Serverul nu răspunde.';
|
||||
|
||||
Reference in New Issue
Block a user