Skip to content

Commit dcc9b51

Browse files
MTSistemiclaude
andcommitted
dashboard: ZeroTier module (remote access from anywhere) + session auto-logout
ZeroTier: node status, join/leave networks by ID, shows the ZeroTier IP to reach the dashboard from anywhere. Session: frontend watcher logs out when the login session expires. zerotier-one installed on the box (1.16.2). 14/15 modules functional. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 772f267 commit dcc9b51

3 files changed

Lines changed: 72 additions & 0 deletions

File tree

apps/dashboard/skillfish-dashboardd

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ MODULE_META = {
3636
"aiops": {"icon": "🩺", "name": "AI-Ops", "name_en": "AI-Ops"},
3737
"rules": {"icon": "⚙️", "name": "Regole auto", "name_en": "Auto rules"},
3838
"wol": {"icon": "🔋", "name": "Power schedule", "name_en": "Power schedule"},
39+
"zerotier": {"icon": "🌐", "name": "ZeroTier", "name_en": "ZeroTier"},
3940
}
4041

4142

@@ -635,6 +636,41 @@ def aiops_diagnose(question):
635636
return {"ok": False, "error": "Ollama: %s" % e}
636637

637638

639+
# ---------------- ZeroTier (remote access from anywhere) ----------------
640+
def _zt(args):
641+
try:
642+
return subprocess.run(["zerotier-cli"] + args, capture_output=True, text=True, timeout=8).stdout.strip()
643+
except Exception:
644+
return ""
645+
646+
647+
def zt_status():
648+
info = _zt(["info"])
649+
parts = info.split()
650+
address = parts[2] if len(parts) > 2 else ""
651+
nets = []
652+
for ln in _zt(["listnetworks"]).splitlines():
653+
p = ln.split()
654+
# 200 listnetworks <nwid> <name> <mac> <status> <type> <dev> <ips>
655+
if len(p) >= 8 and p[1] == "listnetworks" and p[2] != "<nwid>":
656+
ip = p[8] if len(p) > 8 else "-"
657+
nets.append({"nwid": p[2], "name": p[3], "status": p[5], "ip": ip})
658+
return {"ok": True, "address": address, "online": "ONLINE" in info, "networks": nets}
659+
660+
661+
def zt_join(nwid):
662+
if not re.match(r"^[0-9a-fA-F]{16}$", (nwid or "").strip()):
663+
return {"ok": False, "error": "Network ID non valido (16 cifre esadecimali)"}
664+
_zt(["join", nwid.strip()])
665+
return {"ok": True, "nwid": nwid.strip()}
666+
667+
668+
def zt_leave(nwid):
669+
if re.match(r"^[0-9a-fA-F]{16}$", (nwid or "").strip()):
670+
_zt(["leave", nwid.strip()])
671+
return {"ok": True}
672+
673+
638674
# ---------------- HTTP ----------------
639675
CONFIG = load_conf()
640676
_login_fails = {} # ip -> (count, first_ts)
@@ -841,6 +877,10 @@ class Handler(BaseHTTPRequestHandler):
841877
with open(LASTFRAME, "rb") as f:
842878
return self._send(200, f.read(), "image/jpeg")
843879
return self._json(404, {"error": "no frame"})
880+
if path == "/api/zerotier":
881+
if not self._guard("zerotier"):
882+
return
883+
return self._json(200, zt_status())
844884
return self._json(404, {"error": "not found"})
845885

846886
def do_POST(self):
@@ -947,6 +987,14 @@ class Handler(BaseHTTPRequestHandler):
947987
if not self._guard("aiops"):
948988
return
949989
return self._json(200, aiops_diagnose(data.get("question")))
990+
if path == "/api/zerotier/join":
991+
if not self._guard("zerotier"):
992+
return
993+
return self._json(200, zt_join(data.get("nwid")))
994+
if path == "/api/zerotier/leave":
995+
if not self._guard("zerotier"):
996+
return
997+
return self._json(200, zt_leave(data.get("nwid")))
950998
return self._json(404, {"error": "not found"})
951999

9521000
def _login(self):

apps/dashboard/skillfish-remote-manager

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ MODULES = [
3333
("aiops", "🩺", "AI-Ops", True),
3434
("rules", "⚙️", L("Regole auto", "Auto rules"), True),
3535
("wol", "🔋", L("Power schedule", "Power schedule"), True),
36+
("zerotier", "🌐", "ZeroTier", True),
3637
]
3738

3839
CSS = """

apps/dashboard/web/app.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,26 @@ const RENDER = {
253253
$("#adg", card).onclick = async () => { const out = $("#aout", card); out.style.display = "block"; out.textContent = T("ao_running");
254254
const j = await (await post("/api/aiops/diagnose", { question: $("#aq", card).value })).json().catch(() => ({})); out.textContent = j.ok ? (j.answer || T("ao_none")) : (T("err") + (j.error || "")); };
255255
},
256+
zerotier(card) {
257+
const it = LANG === "it";
258+
card.innerHTML = "<h3>🌐 ZeroTier</h3><div id=\"zt\">…</div>";
259+
const refresh = async () => {
260+
let s; try { s = await (await api("/api/zerotier")).json(); } catch (e) { return; }
261+
const nets = (s.networks || []).map(n => {
262+
const ip = (n.ip && n.ip !== "-") ? copyable(n.ip.split(",")[0]) : "—";
263+
const ok = n.status === "OK";
264+
return `<div class="r"><span>${n.nwid} ${ok ? "●" : "○"} ${n.status}</span><span>${ip} <button class="dbtn" data-leave="${n.nwid}" style="padding:1px 8px">×</button></span></div>`;
265+
}).join("") || `<div class="stub">${it ? "Nessuna rete." : "No networks."}</div>`;
266+
$("#zt", card).innerHTML =
267+
'<div class="rows"><div class="r"><span>' + (it ? "Nodo" : "Node") + "</span><span>" + copyable(s.address) + '</span></div><div class="r"><span>' + (it ? "Stato" : "Status") + "</span><span>" + (s.online ? "● ONLINE" : "○ offline") + "</span></div></div>" +
268+
'<div class="gl" style="margin-top:10px">' + (it ? "Reti" : "Networks") + '</div><div class="rows">' + nets + "</div>" +
269+
'<div class="brow" style="margin-top:8px"><input id="ztnw" class="dsel" placeholder="Network ID (16 hex)" style="flex:1"><button class="dbtn" id="ztj">' + (it ? "Entra" : "Join") + "</button></div>" +
270+
'<div class="stub" style="margin-top:8px">' + (it ? "Dopo «Entra», autorizza il nodo su my.zerotier.com. Poi raggiungi la dashboard da ovunque: https://&lt;IP ZeroTier&gt;:8443" : "After Join, authorize the node on my.zerotier.com. Then reach the dashboard from anywhere: https://&lt;ZeroTier IP&gt;:8443") + "</div>";
271+
card.querySelectorAll("[data-leave]").forEach(b => b.onclick = async () => { await action("/api/zerotier/leave", { nwid: b.dataset.leave }, it ? "Uscito dalla rete" : "Left network"); setTimeout(refresh, 800); });
272+
$("#ztj", card).onclick = async () => { await action("/api/zerotier/join", { nwid: $("#ztnw", card).value.trim() }, it ? "Richiesta inviata — autorizza su my.zerotier.com" : "Request sent — authorize on my.zerotier.com"); setTimeout(refresh, 1500); };
273+
};
274+
refresh(); card._iv = setInterval(refresh, 8000);
275+
},
256276
_stub(card, mod) { card.innerHTML = `<h3>${mod.icon} ${LANG === "it" ? mod.name : (mod.name_en || mod.name)}</h3><div class="stub">${LANG === "it" ? "Modulo attivo — interfaccia in arrivo." : "Module on — UI coming soon."}</div>`; },
257277
};
258278

@@ -270,6 +290,9 @@ async function buildDashboard() {
270290
present.forEach(id => { const mod = enabled[id]; const card = document.createElement("div"); card.className = "mod";
271291
(RENDER[id] || ((c) => RENDER._stub(c, mod)))(card, mod); grid.appendChild(card); });
272292
});
293+
// session watcher: if the login session expires, drop back to the login screen
294+
clearInterval(window._sw);
295+
window._sw = setInterval(async () => { try { const r = await api("/api/me"); if (!r.ok) location.reload(); } catch (e) {} }, 60000);
273296
}
274297
function showLogin() {
275298
$("#app").style.display = "none"; $("#login").style.display = "grid";

0 commit comments

Comments
 (0)