diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:08:32 +0200 |
| commit | cfc91e0a424163869c64d30e55d55a53f18a3dbf (patch) | |
| tree | 04ed3bbec11690f62f1e2a738bf89f4b763332e5 /packages/meshbay-node/src/meshbay_node/ui/app.py | |
| parent | ba45a3c94806f612fa62812e0b36d08b581a2e47 (diff) | |
| download | meshbay-cfc91e0a424163869c64d30e55d55a53f18a3dbf.tar.gz | |
refactor(node): JSON-only control API, Node page absorbs the admin dashboard
Remove the node daemon's server-rendered admin UI (GET / and /audit, the
_render_* helpers and inline templates) and the `meshbay-node ui` CLI verb.
The loopback control API stays; it is now JSON only, ruff-clean, and 453
lines (was 1074). Also drop three never-wired endpoints (/api/config,
/api/chat/history, /ws/chat, plus broadcast_chat) and the pointless
18000/tcp firewall profiles.
The desktop client's Node page (static/node-page.js) takes over what the
dashboard showed, reorganised into six tabs (Overview, Groups, Roster,
Peers, Audit, Settings):
- Overview: version, node id, QUIC port, hub, index-cache maintenance
- Roster: node-wide view with unpin
- Peers and Audit: auto-load on open, no Load button
- Audit: real usernames and group names (resolved from the roster and
node.toml), Previous/Next pagination newest-first, Export CSV of every
matching row
- Settings: node settings, STUN, ICE, denylist, then Unlink from hub
Backend: audit.get_entries gains `offset`; /api/audit and /api/peers
resolve ids to names via a new _display_names helper; CSP tightened to
default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and
2.12 corrected -- the Node page uses the loopback API, not MNP.
One capability is intentionally dropped: browser-based admin on a headless
server. The CLI covers every operation there.
See docs/refactor-node-ui.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 727 |
1 files changed, 76 insertions, 651 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 671597f..6fdc78f 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -1,34 +1,25 @@ """ -MeshBay Node — local administration web UI (localhost:18000). +MeshBay Node — local control API (loopback, default port 18000). -FastAPI app providing: - - Dashboard: node status, connected peers, group overview - - Groups: file listing, shared directory info - - Peers: connected WebRTC/QUIC clients - - Audit log: IP + action log for legal compliance - - API endpoints for all data (JSON) +A JSON-only FastAPI app: node status, groups and roots, roster and denylist, +node settings, connected peers, and the audit log. It is the single control +plane for the node — the `meshbay-node` CLI and the desktop client's Node page +are both clients of it. (Chat is served to browsers over MNP/WebRTC, not here.) -Served only on 127.0.0.1 — not exposed to the network. -Gated by a per-run session token (11.5.3) — printed at daemon startup. +Served only on 127.0.0.1 — never network-exposed — and every request is gated +by a per-run session token (11.5.3) written to `<data_dir>/ui-token`. There is +no server-rendered UI: the Node page ships in the desktop client (see +`docs/refactor-node-ui.md`). """ import asyncio -import base64 -import json import logging -import time -from html import escape -from pathlib import Path -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse -from meshbay_node import __version__ -from meshbay_node import ops -from meshbay_node.config import DEFAULT_CONFIG_PATH +from meshbay_node import __version__, ops from meshbay_node.indexer.indexer import DirectoryIndexer -from meshbay_common.crypto import generate_gek, wrap_gek_aes -from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) @@ -50,6 +41,31 @@ def _op(coro): return run() +async def _display_names(state: dict) -> tuple[dict[str, str], dict[str, str]]: + """(user_id -> username, group_id -> name) for rendering ids a human reads. + + Usernames come from the roster (the node's own record); group names from + node.toml. Both are best-effort — a missing entry just leaves the caller + with the raw id to shorten. + """ + users: dict[str, str] = {} + roster = state.get("roster") + if roster: + try: + for ident in await roster.list_identities(): + if ident.get("username"): + users[ident["user_id"]] = ident["username"] + except Exception: + pass + groups: dict[str, str] = {} + config = state.get("config") + if config: + for g in config.groups: + if getattr(g, "id", None): + groups[g.id] = g.name + return users, groups + + def create_ui_app(state: dict) -> FastAPI: app = FastAPI( @@ -62,14 +78,14 @@ def create_ui_app(state: dict) -> FastAPI: @app.middleware("http") async def _require_session_token(request, call_next): """ - Gate the admin UI behind a per-run token (11.5.3). + Gate the control API behind a per-run token (11.5.3). "localhost only" is weaker than it sounds: any process on the machine can reach it, and a page in the operator's browser can reach it too via DNS rebinding. Since this API can re-initialise a group's GEK and read the audit log, an unauthenticated loopback service is a privilege boundary - waiting to be crossed. The token is printed at startup and accepted as - ?t= or the X-MeshBay-Token header. + waiting to be crossed. The token is written to `<data_dir>/ui-token` at + startup and accepted as ?t= or the X-MeshBay-Token header. """ from fastapi.responses import PlainTextResponse @@ -84,25 +100,17 @@ def create_ui_app(state: dict) -> FastAPI: @app.middleware("http") async def _security_headers(request, call_next): """ - Defence in depth behind the escaping fixes for H2. This UI is unauthenticated - on loopback, so script execution here equals full control of the node admin API. - - Note what this does and does not do: the page relies on inline <script>, so - script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an - injected script from running. Escaping is the actual fix. What CSP buys is - containment — connect-src/img-src/form-action 'self'|'none' stop an injected - script from exfiltrating the audit log or config to an external host. + Belt-and-braces for a loopback API that returns only JSON. Since the + server no longer renders any HTML (the dashboard was removed + 2026-09-01), the response has nothing an injected script could live in + — but a DNS-rebound page or a content-sniffing client that manages to + treat a body as a document still gets `default-src 'none'`, which + forbids every fetch, script, style and frame. `nosniff` stops the + sniffing in the first place. """ response = await call_next(request) response.headers["Content-Security-Policy"] = ( - "default-src 'none'; " - "style-src 'unsafe-inline'; " - "script-src 'unsafe-inline'; " - "connect-src 'self'; " - "img-src 'self' data:; " - "form-action 'none'; " - "frame-ancestors 'none'; " - "base-uri 'none'" + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" ) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" @@ -126,7 +134,6 @@ def create_ui_app(state: dict) -> FastAPI: if status == "running": roster = state.get("roster") if roster: - from meshbay_node.roster import Roster members = await roster.list_members() operators = [m for m in members if m["role"] == "operator" and m["status"] == "active"] @@ -239,14 +246,18 @@ def create_ui_app(state: dict) -> FastAPI: webrtc = state.get("webrtc") if not webrtc: return {"peers": []} + users, groups = await _display_names(state) peers = [] for pid, session in list(webrtc._sessions.items()): from meshbay_node.transport.webrtc_server import _get_remote_ip + uid = session._user_id or "" + gid = session._group_id or "" peers.append({ "peer_id": pid, - "user_id": session._user_id or "", - "username": session._username or "", - "group_id": session._group_id or "", + "user_id": uid, + "username": session._username or users.get(uid, ""), + "group_id": gid, + "group_name": groups.get(gid, ""), "remote_ip": session._remote_ip or _get_remote_ip(session._pc), "state": session._pc.connectionState, }) @@ -256,60 +267,44 @@ def create_ui_app(state: dict) -> FastAPI: async def api_audit( since: float = 0, limit: int = 200, + offset: int = 0, user_id: str | None = Query(default=None), event: str | None = Query(default=None), ): audit = state.get("audit_store") if not audit: - return {"entries": []} - entries = await audit.get_entries( - since=since, limit=limit, user_id=user_id, event=event) + return {"entries": [], "offset": 0, "limit": limit, "has_more": False} + limit = max(1, min(limit, 1000)) + offset = max(0, offset) + # Fetch one extra row to know whether a next page exists without a count. + rows = await audit.get_entries( + since=since, limit=limit + 1, offset=offset, + user_id=user_id, event=event) + has_more = len(rows) > limit + entries = rows[:limit] + + # Legacy rows and pre-handshake events store user_id only; group_id is + # never a name. Resolve both for display — no migration, the roster and + # node.toml are the node's own records. + names, groups = await _display_names(state) + return { + "offset": offset, + "limit": limit, + "has_more": has_more, "entries": [ { "id": e.id, "timestamp": e.timestamp, "user_id": e.user_id, - "username": e.username, + "username": e.username or names.get(e.user_id, ""), "ip": e.ip, "event": e.event, "group_id": e.group_id, + "group_name": groups.get(e.group_id, ""), "detail": e.detail, } for e in entries - ] - } - - @app.get("/api/config") - async def api_config(): - config = state.get("config") - if not config: - return {} - return { - "hub_url": config.hub.url, - "username": config.hub.username, - "quic_port": config.node.quic_port, - "ui_port": config.node.ui_port, - "data_dir": str(config.data_dir), - "settings": { - "invite_ttl_hours": config.node.invite_ttl_hours, - "pair_ttl_hours": config.node.pair_ttl_hours, - "device_request_ttl_minutes": config.node.device_request_ttl_minutes, - "max_concurrent_streams": config.node.max_concurrent_streams, - "transcode_incompatible_video": config.node.transcode_incompatible_video, - }, - "groups": [ - { - "id": g.id, - "name": g.name, - "roots": [ - {"path": r.path, "name": r.name, "kind": r.kind, - "upload": r.upload} - for r in g.roots - ], - "visibility": g.visibility, - } - for g in config.groups ], } @@ -455,574 +450,4 @@ def create_ui_app(state: dict) -> FastAPI: async def update_node_settings(payload: dict): return await _op(lambda: ops.set_node_settings(state, payload)) - # ── Chat endpoints ─────────────────────────────────────────────────────── - - _chat_subscribers: list[WebSocket] = [] - - @app.get("/api/chat/history") - async def chat_history(since: float = 0, limit: int = 100): - chat_store = state.get("chat_store") - if not chat_store: - return {"messages": []} - msgs = await chat_store.get_messages(since=since, limit=limit) - return { - "messages": [ - { - "id": m.id, - "sender_id": m.sender_id, - "iteration": m.iteration, - "timestamp": m.timestamp, - "thread_id": m.thread_id, - } - for m in msgs - ] - } - - @app.websocket("/ws/chat") - async def chat_websocket(ws: WebSocket): - await ws.accept() - _chat_subscribers.append(ws) - try: - while True: - await ws.receive_text() - except WebSocketDisconnect: - pass - finally: - _chat_subscribers.remove(ws) - - async def broadcast_chat_to_ui(msg: dict) -> None: - payload = json.dumps(msg) - dead = [] - for ws in _chat_subscribers: - try: - await ws.send_text(payload) - except Exception: - dead.append(ws) - for ws in dead: - _chat_subscribers.remove(ws) - - app.broadcast_chat = broadcast_chat_to_ui - - # ── HTML UI ────────────────────────────────────────────────────────────── - - @app.get("/", response_class=HTMLResponse) - async def root(): - # Roster reads are async and the page renderer is not, so gather here. - roster = state.get("roster") - roster_view = None - if roster: - identities = {i["user_id"]: i for i in await roster.list_identities()} - roster_view = { - "identities": identities, - "members": await roster.list_members(), - "invites": await roster.list_invites(), - } - index_cache = state.get("index_cache") - cache_count = await index_cache.count() if index_cache else None - return _render_page(state, roster_view, cache_count) - - @app.get("/audit", response_class=HTMLResponse) - async def audit_page(): - return _render_audit_page(state.get("ui_token", "")) - return app - - -def _fmt_size(n: int) -> str: - if n < 1024: - return f"{n} B" - if n < 1024 * 1024: - return f"{n / 1024:.1f} KB" - if n < 1024 * 1024 * 1024: - return f"{n / (1024 * 1024):.1f} MB" - return f"{n / (1024 * 1024 * 1024):.2f} GB" - - -def _render_node_settings(config) -> str: - if not config: - return "" - nd = config.node - transcode = "on" if nd.transcode_incompatible_video else "off" - return f""" - <table style="margin-top:10px"> - <thead><tr><th>Setting</th><th>Value</th></tr></thead> - <tbody> - <tr><td>Invitation TTL</td><td>{nd.invite_ttl_hours} hours</td></tr> - <tr><td>Pairing code TTL</td><td>{nd.pair_ttl_hours} hours</td></tr> - <tr><td>Device request TTL</td><td>{nd.device_request_ttl_minutes} minutes</td></tr> - <tr><td>Max concurrent streams</td><td>{nd.max_concurrent_streams}</td></tr> - <tr><td>Transcode incompatible video</td><td>{transcode}</td></tr> - </tbody> - </table>""" - - -def _render_roster(roster_view: dict | None) -> str: - """ - Who this node recognises, and which keys are theirs. - - Every value here is escaped: usernames come from the hub and pass through the - roster, so they are attacker-influenced text on the operator's own admin page - (the H2 rule applies to them exactly as it does to filenames). - """ - if roster_view is None: - return '<p class="muted">Roster unavailable</p>' - - identities = roster_view["identities"] - rows = "" - for m in roster_view["members"]: - ident = identities.get(m["user_id"], {}) - scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide" - status_color = "#22c55e" if m["status"] == "active" else "#ef4444" - rows += ( - f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>" - f"<td>{escape(str(m['role']))}</td>" - f"<td><span class='badge' style='background:{status_color}'>" - f"{escape(str(m['status']))}</span></td>" - f"<td>{scope}</td>" - f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>" - f"<td>{escape(str(ident.get('pinned_at', '?')))} " - f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>" - ) - if not rows: - rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — ' - 'run <code>meshbay-node member invite <username></code></td></tr>') - - invite_rows = "" - for i in roster_view["invites"]: - invite_rows += ( - f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>" - f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>" - f"<td>{escape(str(i['role']))}</td>" - f"<td>{escape(str(i['expires_at']))}</td></tr>" - ) - invites_html = "" - if invite_rows: - invites_html = f""" - <details style="margin-top:10px"><summary>Pending invitations</summary> - <table> - <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead> - <tbody>{invite_rows}</tbody> - </table> - </details>""" - - return f""" - <table> - <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th> - <th>Identity key</th><th>Pinned</th></tr></thead> - <tbody>{rows}</tbody> - </table> - {invites_html} - <p class="muted" style="margin-top:8px"> - Codes are issued from the CLI: <code>meshbay-node operator pair</code>, - <code>meshbay-node member invite <username></code>. They never pass - through the hub. - </p>""" - - -def _render_page(state: dict, roster_view: dict | None = None, - index_cache_count: int | None = None) -> str: - token_js = json.dumps(state.get("ui_token", "")) - status = state.get("status", "starting") - indexes = state.get("indexes", {}) - groups_ctx = state.get("groups_ctx", {}) - config = state.get("config") - webrtc = state.get("webrtc") - total_files = sum(idx.count for idx in indexes.values()) - peer_count = webrtc.active_peers if webrtc else 0 - status_color = { - "running": "#22c55e", "error": "#ef4444", - "waiting_for_node_key": "#f97316", - }.get(status, "#f59e0b") - - # Groups section - groups_html = "" - for gid, ctx in groups_ctx.items(): - cfg = None - if config: - cfg = next((g for g in config.groups if g.id == gid), None) - idx = ctx.get("index") - name = cfg.name if cfg else gid[:8] - roots = ctx.get("roots") - # An unavailable root is shown as such rather than hidden: its files are - # still listed and still in the index, and hiding the root would make a - # frozen library look deleted — the exact confusion this is meant to - # prevent. - shared = ", ".join( - f"{r.name} → {r.path}" + ("" if r.available else " [UNAVAILABLE]") - for r in roots - ) if roots else "" - vis = cfg.visibility if cfg else "private" - fcount = idx.count if idx else 0 - total_size = sum(e.size for e in idx.entries) if idx else 0 - - # Everything interpolated below is attacker-controlled: filenames come from - # uploads by any group member. Rendering them raw was a stored XSS into the - # unauthenticated localhost admin UI, i.e. full control of the node admin API - # from the operator's browser (finding H2). - file_rows = "" - if idx: - for e in sorted(idx.entries, key=lambda x: x.name): - file_rows += ( - f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>" - f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>" - ) - - has_gek = bool(ctx.get("gek")) - gek_badge = ( - '<span class="badge" style="background:#22c55e">GEK active</span>' - if has_gek - else '<span class="badge" style="background:#ef4444">No GEK</span>' - ) - gek_label = "Re-wrap GEK for all members" if has_gek else "Initialize GEK" - gek_color = "#3b82f6" if has_gek else "#22c55e" - gek_action = f""" - <div style="margin:10px 0"> - <button onclick="initGEK('{gid}')" - id="gek-btn-{gid[:8]}" - style="padding:8px 16px;background:{gek_color};color:#fff;border:none; - border-radius:6px;cursor:pointer;font-size:0.85em"> - {gek_label} - </button> - <span id="gek-status-{gid[:8]}" class="muted" style="margin-left:8px"></span> - </div>""" - - groups_html += f""" - <div class="card"> - <h3>{escape(str(name))} - <span class="badge" style="background:#6366f1">{escape(str(vis))}</span> - {gek_badge} - </h3> - <p><b>Directory:</b> <code>{escape(str(shared))}</code></p> - <p><b>Files:</b> {fcount} — <b>Total:</b> {_fmt_size(total_size)}</p> - {gek_action} - <p class="muted">ID: {escape(gid)}</p> - <details><summary>File list</summary> - <table> - <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead> - <tbody>{file_rows}</tbody> - </table> - </details> - </div>""" - - # Peers section - peers_html = "" - if webrtc: - for pid, session in list(webrtc._sessions.items()): - from meshbay_node.transport.webrtc_server import _get_remote_ip - ip = session._remote_ip or _get_remote_ip(session._pc) - peers_html += ( - f"<tr><td>{escape(session._username or session._user_id or '—')}</td>" - f"<td>{escape(ip or '—')}</td>" - f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>" - f"<td>{escape(session._pc.connectionState)}</td></tr>" - ) - if not peers_html: - peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>' - - return f"""<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<title>MeshBay Node Admin</title> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<style> - :root {{ - --bg: #0f172a; --surface: #1e293b; --border: #334155; - --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6; - --green: #22c55e; --red: #ef4444; --yellow: #f59e0b; - }} - * {{ box-sizing: border-box; margin: 0; padding: 0; }} - body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); color: var(--text); }} - .container {{ max-width: 1000px; margin: 0 auto; padding: 20px; }} - h1 {{ font-size: 1.5em; margin-bottom: 20px; }} - h2 {{ font-size: 1.2em; margin: 24px 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }} - h3 {{ font-size: 1em; margin-bottom: 8px; }} - .badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; - color: #fff; font-size: 0.8em; font-weight: 600; vertical-align: middle; }} - .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 12px; margin-bottom: 20px; }} - .stat {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; - padding: 16px; text-align: center; }} - .stat .value {{ font-size: 1.8em; font-weight: 700; color: var(--accent); }} - .stat .label {{ font-size: 0.8em; color: var(--muted); margin-top: 4px; }} - .card {{ background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; padding: 16px; margin-bottom: 12px; }} - table {{ width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 8px; }} - th, td {{ padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }} - th {{ color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; }} - code {{ background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }} - .muted {{ color: var(--muted); font-size: 0.85em; }} - details summary {{ cursor: pointer; color: var(--accent); font-size: 0.85em; margin-top: 8px; }} - a {{ color: var(--accent); text-decoration: none; }} - a:hover {{ text-decoration: underline; }} - nav {{ display: flex; gap: 16px; margin-bottom: 20px; }} - nav a {{ padding: 6px 12px; border-radius: 6px; background: var(--surface); - border: 1px solid var(--border); }} - nav a:hover {{ background: var(--border); text-decoration: none; }} - .footer {{ margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--border); - font-size: 0.8em; color: var(--muted); }} -</style> -</head> -<body> -<div class="container"> - <h1>MeshBay Node <span class="badge" style="background:{status_color}">{status}</span></h1> - - <nav> - <a href="/">Dashboard</a> - <a href="/audit">Audit Log</a> - <a href="/api/status">API</a> - </nav> - - <div class="stats"> - <div class="stat"><div class="value">{len(groups_ctx)}</div><div class="label">Groups</div></div> - <div class="stat"><div class="value">{total_files}</div><div class="label">Files</div></div> - <div class="stat"><div class="value">{peer_count}</div><div class="label">Connected Peers</div></div> - <div class="stat"> - <div class="value" style="font-size:1em;word-break:break-all">{state.get("username", "—")}</div> - <div class="label">User</div> - </div> - </div> - - <h2>Connected Peers</h2> - <table> - <thead><tr><th>User</th><th>IP</th><th>Group</th><th>State</th></tr></thead> - <tbody>{peers_html}</tbody> - </table> - - <h2>Roster</h2> - {_render_roster(roster_view)} - - <h2>Groups</h2> - {groups_html or '<p class="muted">No groups configured</p>'} - - <h2>Node Configuration</h2> - <div class="card"> - <p><b>Hub:</b> {state.get("hub_url", "—")}</p> - <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p> - <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p> - {_render_node_settings(config)} - </div> - - <h2>Maintenance</h2> - <div class="card"> - <p><b>Index cache:</b> <span id="cacheCount">{ - index_cache_count if index_cache_count is not None else "—" - }</span> path(s) remembered (size/mtime → hash, shared by every group)</p> - <p class="muted">Removes rows whose path no longer belongs to any group's - root, or whose file is genuinely gone from a root that is currently - reachable. Never touches a root that is temporarily unavailable - (unplugged drive) — that one still needs its full cache back the - moment it returns.</p> - <div style="margin:10px 0"> - <button onclick="pruneIndexCache()" id="prune-cache-btn" - style="padding:8px 16px;background:#3b82f6;color:#fff;border:none; - border-radius:6px;cursor:pointer;font-size:0.85em"> - Prune stale entries - </button> - <span id="prune-cache-status" class="muted" style="margin-left:8px"></span> - </div> - </div> - - <h2>Link Node to Hub Account</h2> - <div class="card"> - <p>To connect to your group from a browser, link this node to your hub account. - Copy the key below and paste it in <b>Settings > Link Node</b> on the hub.</p> - <div style="margin:12px 0;display:flex;align-items:center;gap:8px"> - <code id="nodeKey" style="flex:1;padding:8px;word-break:break-all;background:var(--border); - border-radius:4px;font-size:0.9em;user-select:all">{state.get("pk_node_ed25519", "—")}</code> - <button onclick="navigator.clipboard.writeText(document.getElementById('nodeKey').textContent).then(()=>{{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)}})" - style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px; - cursor:pointer;font-size:0.85em;white-space:nowrap">Copy</button> - </div> - <p class="muted">This is the node's Ed25519 public key. It's safe to share — it identifies - this node but cannot be used to impersonate it.</p> - </div> - - <div class="footer"> - MeshBay Node v{__version__} — localhost only — - <a href="/api/status">status</a> · - <a href="/api/groups">groups</a> · - <a href="/api/peers">peers</a> · - <a href="/api/audit">audit</a> · - <a href="/api/config">config</a> - — auto-refresh 10s - </div> -</div> -<script> -const TOKEN = {token_js}; -async function initGEK(groupId) {{ - const btn = document.getElementById('gek-btn-' + groupId.slice(0,8)); - const status = document.getElementById('gek-status-' + groupId.slice(0,8)); - if (btn) btn.disabled = true; - if (status) status.textContent = 'Initializing...'; - try {{ - const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }}); - const data = await resp.json(); - if (resp.ok) {{ - if (status) status.textContent = 'GEK initialized — ' - + data.authorized_members + ' authorized member(s) get it on connect'; - if (status) status.style.color = '#22c55e'; - setTimeout(() => location.reload(), 2000); - }} else {{ - if (status) status.textContent = data.error || 'Failed'; - if (status) status.style.color = '#ef4444'; - if (btn) btn.disabled = false; - }} - }} catch (e) {{ - if (status) status.textContent = 'Error: ' + e.message; - if (status) status.style.color = '#ef4444'; - if (btn) btn.disabled = false; - }} -}} -async function pruneIndexCache() {{ - const btn = document.getElementById('prune-cache-btn'); - const status = document.getElementById('prune-cache-status'); - if (btn) btn.disabled = true; - if (status) {{ status.textContent = 'Pruning...'; status.style.color = ''; }} - try {{ - const resp = await fetch('/api/index-cache/prune?t=' + TOKEN, {{ method: 'POST' }}); - const data = await resp.json(); - if (resp.ok) {{ - if (status) status.textContent = 'Removed ' + data.removed + ', kept ' + data.kept; - if (status) status.style.color = '#22c55e'; - const count = document.getElementById('cacheCount'); - if (count) count.textContent = data.kept; - }} else {{ - if (status) status.textContent = data.error || 'Failed'; - if (status) status.style.color = '#ef4444'; - }} - }} catch (e) {{ - if (status) status.textContent = 'Error: ' + e.message; - if (status) status.style.color = '#ef4444'; - }} finally {{ - if (btn) btn.disabled = false; - }} -}} -setTimeout(()=>location.reload(), 10000); -</script> -</body> -</html>""" - - -def _render_audit_page(token: str = "") -> str: - return _AUDIT_HTML.replace("__TOKEN__", json.dumps(token)) - - -_AUDIT_HTML = """<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<title>MeshBay Node — Audit Log</title> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<style> - :root { - --bg: #0f172a; --surface: #1e293b; --border: #334155; - --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6; - } - * { box-sizing: border-box; margin: 0; padding: 0; } - body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); color: var(--text); } - .container { max-width: 1100px; margin: 0 auto; padding: 20px; } - h1 { font-size: 1.5em; margin-bottom: 16px; } - nav { display: flex; gap: 16px; margin-bottom: 20px; } - nav a { padding: 6px 12px; border-radius: 6px; background: var(--surface); - border: 1px solid var(--border); color: var(--accent); text-decoration: none; } - nav a:hover { background: var(--border); } - .filters { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } - .filters select, .filters input { - background: var(--surface); color: var(--text); border: 1px solid var(--border); - padding: 6px 10px; border-radius: 6px; font-size: 0.85em; - } - table { width: 100%; border-collapse: collapse; font-size: 0.82em; } - th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--border); } - th { color: var(--muted); font-weight: 600; font-size: 0.75em; text-transform: uppercase; - position: sticky; top: 0; background: var(--bg); } - .muted { color: var(--muted); } - #count { margin-bottom: 10px; font-size: 0.85em; color: var(--muted); } -</style> -</head> -<body> -<div class="container"> - <h1>Audit Log</h1> - <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav> - - <div class="filters"> - <select id="eventFilter"> - <option value="">All events</option> - <option value="handshake">handshake</option> - <option value="file_download">file_download</option> - <option value="file_upload">file_upload</option> - <option value="file_delete">file_delete</option> - <option value="stream_video">stream_video</option> - <option value="chat_message">chat_message</option> - <option value="disconnect">disconnect</option> - <option value="auth_failed">auth_failed</option> - </select> - <input id="userFilter" placeholder="Filter by user..." /> - <select id="limitSelect"> - <option value="100">100 entries</option> - <option value="500">500 entries</option> - <option value="1000">1000 entries</option> - </select> - </div> - - <div id="count"></div> - <table> - <thead><tr><th>Time</th><th>Event</th><th>User</th><th>IP</th><th>Group</th><th>Detail</th></tr></thead> - <tbody id="tbody"></tbody> - </table> -</div> -<script> -const TOKEN = __TOKEN__; -async function load() { - const ev = document.getElementById('eventFilter').value; - const limit = document.getElementById('limitSelect').value; - let url = '/api/audit?limit=' + limit + (TOKEN ? '&t=' + TOKEN : ''); - if (ev) url += '&event=' + ev; - const r = await fetch(url); - const data = await r.json(); - const tbody = document.getElementById('tbody'); - document.getElementById('count').textContent = data.entries.length + ' entries'; - // textContent, not innerHTML: e.detail carries filenames chosen by group members - // (finding H2). Building this row with string concatenation was a stored XSS. - tbody.replaceChildren(...data.entries.map(e => { - const tr = document.createElement('tr'); - const cells = [ - new Date(e.timestamp * 1000).toLocaleString(), - e.event, - e.username || (e.user_id || '').slice(0, 8), - e.ip || '—', - e.group_id ? e.group_id.slice(0, 8) : '—', - e.detail || '', - ]; - for (const value of cells) { - const td = document.createElement('td'); - td.textContent = value; - tr.appendChild(td); - } - return tr; - })); -} -for (const [id, href] of [['navHome','/'],['navAudit','/audit']]) { - const el = document.getElementById(id); - if (el && TOKEN) el.href = href + '?t=' + TOKEN; -} -document.getElementById('eventFilter').onchange = load; -document.getElementById('limitSelect').onchange = load; -let debounceTimer; -document.getElementById('userFilter').oninput = function() { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - const val = this.value; - const rows = document.querySelectorAll('#tbody tr'); - rows.forEach(r => { - r.style.display = r.textContent.toLowerCase().includes(val.toLowerCase()) ? '' : 'none'; - }); - }, 200); -}; -load(); -setInterval(load, 15000); -</script> -</body> -</html>""" |