From 35130e5528a52161630fd1c93572e1b2b7cd911b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 23:11:36 +0200 Subject: 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 --- packages/meshbay-node/src/meshbay_node/ui/app.py | 542 +++++++++++++++++------ 1 file changed, 409 insertions(+), 133 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/ui/app.py') 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") - - files_html = "" - if index: - rows = "".join( - f"{e.name}{e.type}" - f"{e.size // 1024} KB{e.path or '/'}" - for e in index.entries - ) - files_html = f""" -

Files ({file_count})

- - - {rows} -
NameTypeSizePath
""" - - return f""" - - - - MeshBay Node - - - - -

🔗 MeshBay Node {status}

-

- Hub: {state.get("hub_url", "—")}  |  - User: {state.get("username", "—")}  |  - Group: {state.get("group_name") or state.get("group_id") or "—"}  |  - Port: {state.get("node_port", "—")} -

-

Endpoint: {state.get("endpoint_hint") or "unknown"}

- {files_html} -
- MeshBay Node v{__version__} — JSON status - — JSON files — Chat - -""" + @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} + + @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 + ] + } + + @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""" + # ── 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"{e.name}{e.type}" + f"{_fmt_size(e.size)}{e.path or '/'}" + ) + + groups_html += f""" +
+

{name} + {vis} +

+

Directory: {shared}

+

Files: {fcount} — Total: {_fmt_size(total_size)}

+

ID: {gid}

+
File list + + + {file_rows} +
NameTypeSizePath
+
+
""" + + # 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"{session._username or session._user_id or '—'}" + f"{ip or '—'}" + f"{session._group_id[:8] if session._group_id else '—'}" + f"{session._pc.connectionState}" + ) + if not peers_html: + peers_html = 'No connected peers' + + return f""" - - MeshBay Chat - + +MeshBay Node Admin + + -

MeshBay Chat

-
-

Back to status

- +
+

MeshBay Node {status}

+ + + +
+
{len(groups_ctx)}
Groups
+
{total_files}
Files
+
{peer_count}
Connected Peers
+
+
{state.get("username", "—")}
+
User
+
+
+ +

Connected Peers

+ + + {peers_html} +
UserIPGroupState
+ +

Groups

+ {groups_html or '

No groups configured

'} + +

Node Configuration

+
+

Hub: {state.get("hub_url", "—")}

+

QUIC port: {state.get("quic_port", "—")} — + TCP port: {state.get("node_port", "—")}

+

Node ID: {state.get("endpoint_hint") or "—"}

+
+ + +
+ """ - return app + +def _render_audit_page() -> str: + return """ + + + +MeshBay Node — Audit Log + + + + +
+

Audit Log

+ + +
+ + + +
+ +
+ + + +
TimeEventUserIPGroupDetail
+
+ + +""" -- cgit v1.2.3