diff --git a/CLAUDE.md b/CLAUDE.md
index a9e3644..57c5b58 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,7 +47,7 @@ input/ # Oracle DMP files for import
- **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.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`
-- **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`
- **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`
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md
index 56e41d6..f7f8a57 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/README.md
+++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md
@@ -189,12 +189,15 @@ systemctl --user restart claude-discord # repornire
Aceleasi lucruri, cu butoane, in **dashboard-ul de control**
([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 |
|--------|------|
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md
index 178a8d9..589ec18 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md
@@ -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)`.
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
index fbca525..207b9b6 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
@@ -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":
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
index c935b74..fd8e099 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
@@ -4,8 +4,8 @@
Punte Discord — control
-
-
+
+
@@ -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 '
' +
@@ -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)';
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html
index cdf5d52..f3403c6 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html
+++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/login.html
@@ -4,8 +4,8 @@
Autentificare — Punte Discord
-
-
+
+
@@ -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.';
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example
index b486fdb..6d81284 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example
+++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example
@@ -60,3 +60,7 @@ DASHBOARD_TOKEN=
# spre tot clusterul, deci accesul se face prin tunel SSH, nu expus in LAN.
DASHBOARD_BIND=127.0.0.1
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
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh
index 5b4cbd3..0c90a97 100755
--- a/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh
+++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh
@@ -137,6 +137,25 @@ else
rm -f "$TMP_CRON"
fi
+# --- 6b. tailscale serve pentru dashboard ----------------------------------
+# Publica panoul in tailnet la https://..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 ---------------------------------------------------------------
if command -v mail >/dev/null 2>&1; then
info "binarul mail: $(command -v mail)"
diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py
index 9d1c5ff..bd89520 100644
--- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py
+++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py
@@ -334,3 +334,50 @@ def test_ruta_necunoscuta(server):
server.login()
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"]