diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 23:11:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 23:11:36 +0200 |
| commit | 35130e5528a52161630fd1c93572e1b2b7cd911b (patch) | |
| tree | 50954e2da56e186e2651ad838093eb6c09c259a8 /packages/meshbay-node/src/meshbay_node/ui | |
| parent | c66ee41d8476461939c5f4e7fdc71c5d7fb4a85c (diff) | |
| download | meshbay-35130e5528a52161630fd1c93572e1b2b7cd911b.tar.gz | |
feat(node): audit logging + local admin UI rewrite
Add SQLite audit store for legal compliance (LCEN/DSA): logs user IP,
actions (handshake, file download/upload/delete, stream, chat), and
timestamps. Retention: 1 year, with cleanup method.
WebRTC transport now logs all user actions to the audit store with
remote IP extraction from the ICE transport.
Local web UI rewritten as a proper admin dashboard:
- Stats cards (groups, files, peers)
- Connected peers table with IP, username, group, state
- Group cards with file listings and shared directory info
- Audit log page with event/user filtering
- Dark theme, responsive, auto-refresh
- JSON API: /api/status, /api/groups, /api/peers, /api/audit, /api/config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 538 |
1 files changed, 407 insertions, 131 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index a63e28f..5e77ed8 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -1,138 +1,171 @@ """ -MeshBay Node — local web UI (localhost:18000). +MeshBay Node — local administration web UI (localhost:18000). -Minimal FastAPI app providing: - GET / → status page (HTML) - GET /api/status → JSON status - GET /api/files → JSON file list from the group index - GET /api/config → JSON config summary (no secrets) +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) Served only on 127.0.0.1 — not exposed to the network. +No authentication required (localhost only). """ -import asyncio import json import logging -from typing import TYPE_CHECKING +import time +from pathlib import Path -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query from fastapi.responses import HTMLResponse from meshbay_node import __version__ -if TYPE_CHECKING: - from meshbay_node.chat.store import ChatStore - from meshbay_node.indexer import GroupIndex - log = logging.getLogger(__name__) def create_ui_app(state: dict) -> FastAPI: - """ - Create the UI FastAPI app. - - state dict is updated by the daemon and read by UI endpoints: - state["status"] : str — "starting" | "running" | "error" - state["group_id"] : str - state["group_name"] : str - state["hub_url"] : str - state["username"] : str - state["node_port"] : int - state["index"] : GroupIndex | None - state["endpoint_hint"]: str | None - """ app = FastAPI( - title="MeshBay Node UI", + title="MeshBay Node Admin", version=__version__, - docs_url=None, # disable Swagger on local UI + docs_url=None, redoc_url=None, ) + # ── JSON API ───────────────────────────────────────────────────────────── + @app.get("/api/status") async def api_status(): - index = state.get("index") + indexes = state.get("indexes", {}) + total_files = sum(idx.count for idx in indexes.values()) + groups_ctx = state.get("groups_ctx", {}) + webrtc = state.get("webrtc") return { - "version": __version__, - "status": state.get("status", "starting"), - "hub_url": state.get("hub_url", ""), - "username": state.get("username", ""), - "group_id": state.get("group_id", ""), - "group_name": state.get("group_name", ""), - "node_port": state.get("node_port", 0), + "version": __version__, + "status": state.get("status", "starting"), + "hub_url": state.get("hub_url", ""), + "username": state.get("username", ""), + "node_port": state.get("node_port", 0), + "quic_port": state.get("quic_port", 0), "endpoint_hint": state.get("endpoint_hint"), - "file_count": index.count if index else 0, - "index_version": index.version if index else 0, + "group_count": len(groups_ctx), + "total_files": total_files, + "webrtc_peers": webrtc.active_peers if webrtc else 0, } - @app.get("/api/files") - async def api_files(): - index = state.get("index") - if not index: + @app.get("/api/groups") + async def api_groups(): + groups_ctx = state.get("groups_ctx", {}) + config = state.get("config") + result = [] + 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") + result.append({ + "id": gid, + "name": cfg.name if cfg else gid[:8], + "shared_dir": str(ctx.get("shared_root", "")), + "visibility": cfg.visibility if cfg else "private", + "file_count": idx.count if idx else 0, + "index_version": idx.version if idx else 0, + }) + return {"groups": result} + + @app.get("/api/groups/{group_id}/files") + async def api_group_files(group_id: str): + groups_ctx = state.get("groups_ctx", {}) + ctx = groups_ctx.get(group_id) + if not ctx: + return {"files": []} + idx = ctx.get("index") + if not idx: return {"files": []} return { "files": [ { - "id": e.id[:16] + "…", - "name": e.name, - "path": e.path, - "size": e.size, - "type": e.type, - "duration": e.duration, + "id": e.id, + "name": e.name, + "path": e.path, + "size": e.size, + "type": e.type, + "added_at": e.added_at, } - for e in index.entries + for e in idx.entries ] } - @app.get("/", response_class=HTMLResponse) - async def root(): - index = state.get("index") - status = state.get("status", "starting") - file_count = index.count if index else 0 - status_color = {"running": "#22c55e", "error": "#ef4444"}.get(status, "#f59e0b") + @app.get("/api/peers") + async def api_peers(): + webrtc = state.get("webrtc") + if not webrtc: + return {"peers": []} + peers = [] + for pid, session in list(webrtc._sessions.items()): + from meshbay_node.transport.webrtc_server import _get_remote_ip + peers.append({ + "peer_id": pid, + "user_id": session._user_id or "", + "username": session._username or "", + "group_id": session._group_id or "", + "remote_ip": session._remote_ip or _get_remote_ip(session._pc), + "state": session._pc.connectionState, + }) + return {"peers": peers} - files_html = "" - if index: - rows = "".join( - f"<tr><td>{e.name}</td><td>{e.type}</td>" - f"<td>{e.size // 1024} KB</td><td>{e.path or '/'}</td></tr>" - for e in index.entries - ) - files_html = f""" - <h2>Files ({file_count})</h2> - <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%"> - <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead> - <tbody>{rows}</tbody> - </table>""" + @app.get("/api/audit") + async def api_audit( + since: float = 0, + limit: int = 200, + 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": [ + { + "id": e.id, + "timestamp": e.timestamp, + "user_id": e.user_id, + "username": e.username, + "ip": e.ip, + "event": e.event, + "group_id": e.group_id, + "detail": e.detail, + } + for e in entries + ] + } - return f"""<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <title>MeshBay Node</title> - <style> - body {{ font-family: monospace; max-width: 900px; margin: 40px auto; padding: 0 20px; }} - .badge {{ display:inline-block; padding:3px 10px; border-radius:4px; - color:#fff; background:{status_color}; font-weight:bold; }} - table {{ font-size: 0.9em; }} - th {{ background: #f3f4f6; }} - </style> - <meta http-equiv="refresh" content="10"> -</head> -<body> - <h1>🔗 MeshBay Node <span class="badge">{status}</span></h1> - <p> - <b>Hub:</b> {state.get("hub_url", "—")} | - <b>User:</b> {state.get("username", "—")} | - <b>Group:</b> {state.get("group_name") or state.get("group_id") or "—"} | - <b>Port:</b> {state.get("node_port", "—")} - </p> - <p><b>Endpoint:</b> {state.get("endpoint_hint") or "unknown"}</p> - {files_html} - <hr> - <small>MeshBay Node v{__version__} — <a href="/api/status">JSON status</a> - — <a href="/api/files">JSON files</a> — <a href="/chat">Chat</a></small> -</body> -</html>""" + @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, + "node_port": config.node.port, + "quic_port": config.node.quic_port, + "http_port": config.node.http_port, + "ui_port": config.node.ui_port, + "data_dir": str(config.data_dir), + "groups": [ + { + "id": g.id, + "name": g.name, + "shared_dir": g.shared_dir, + "visibility": g.visibility, + } + for g in config.groups + ], + } # ── Chat endpoints ─────────────────────────────────────────────────────── @@ -159,7 +192,6 @@ def create_ui_app(state: dict) -> FastAPI: @app.websocket("/ws/chat") async def chat_websocket(ws: WebSocket): - """WebSocket for real-time chat push to the local UI.""" await ws.accept() _chat_subscribers.append(ws) try: @@ -171,7 +203,6 @@ def create_ui_app(state: dict) -> FastAPI: _chat_subscribers.remove(ws) async def broadcast_chat_to_ui(msg: dict) -> None: - """Push a chat message to all connected UI WebSocket clients.""" payload = json.dumps(msg) dead = [] for ws in _chat_subscribers: @@ -184,42 +215,287 @@ def create_ui_app(state: dict) -> FastAPI: app.broadcast_chat = broadcast_chat_to_ui - @app.get("/chat", response_class=HTMLResponse) - async def chat_page(): - return f"""<!DOCTYPE html> + # ── HTML UI ────────────────────────────────────────────────────────────── + + @app.get("/", response_class=HTMLResponse) + async def root(): + return _render_page(state) + + @app.get("/audit", response_class=HTMLResponse) + async def audit_page(): + return _render_audit_page() + + 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_page(state: dict) -> str: + 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"}.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] + shared = ctx.get("shared_root", "") + 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 + + file_rows = "" + if idx: + for e in sorted(idx.entries, key=lambda x: x.name): + file_rows += ( + f"<tr><td>{e.name}</td><td>{e.type}</td>" + f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>" + ) + + groups_html += f""" + <div class="card"> + <h3>{name} + <span class="badge" style="background:#6366f1">{vis}</span> + </h3> + <p><b>Directory:</b> <code>{shared}</code></p> + <p><b>Files:</b> {fcount} — <b>Total:</b> {_fmt_size(total_size)}</p> + <p class="muted">ID: {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>{session._username or session._user_id or '—'}</td>" + f"<td>{ip or '—'}</td>" + f"<td>{session._group_id[:8] if session._group_id else '—'}</td>" + f"<td>{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 Chat</title> - <style> - body {{ font-family: monospace; max-width: 700px; margin: 40px auto; padding: 0 20px; }} - #messages {{ border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: auto; - background: #fafafa; margin-bottom: 10px; }} - .msg {{ margin: 4px 0; }} - .sender {{ font-weight: bold; color: #2563eb; }} - .time {{ color: #9ca3af; font-size: 0.8em; }} - </style> +<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> - <h1>MeshBay Chat</h1> - <div id="messages"></div> - <p><a href="/">Back to status</a></p> - <script> - const box = document.getElementById('messages'); - const ws = new WebSocket('ws://' + location.host + '/ws/chat'); - ws.onmessage = (e) => {{ - const msg = JSON.parse(e.data); - const div = document.createElement('div'); - div.className = 'msg'; - const t = new Date(msg.timestamp * 1000).toLocaleTimeString(); - div.innerHTML = '<span class="time">' + t + '</span> ' - + '<span class="sender">' + msg.sender_id + '</span>: ' - + '(encrypted message #' + msg.iteration + ')'; - box.appendChild(div); - box.scrollTop = box.scrollHeight; - }}; - </script> +<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>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", "—")} — + <b>TCP port:</b> {state.get("node_port", "—")}</p> + <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></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>setTimeout(()=>location.reload(), 10000);</script> </body> </html>""" - return app + +def _render_audit_page() -> str: + return """<!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 href="/">Dashboard</a><a 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> +async function load() { + const ev = document.getElementById('eventFilter').value; + const limit = document.getElementById('limitSelect').value; + let url = '/api/audit?limit=' + limit; + 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'; + tbody.innerHTML = data.entries.map(e => { + const t = new Date(e.timestamp * 1000).toLocaleString(); + return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>' + + (e.username || e.user_id.slice(0,8)) + '</td><td>' + + (e.ip || '—') + '</td><td>' + + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>' + + (e.detail || '') + '</td></tr>'; + }).join(''); +} +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>""" |