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 | |
| 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')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/audit.py | 148 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 59 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 538 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_audit.py | 76 |
5 files changed, 705 insertions, 133 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/audit.py b/packages/meshbay-node/src/meshbay_node/audit.py new file mode 100644 index 0000000..6346f16 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/audit.py @@ -0,0 +1,148 @@ +""" +MeshBay Node — SQLite audit log for legal compliance. + +Logs user actions with IP address, timestamp, and details. +Required by LCEN (France), EU e-Commerce Directive, and DSA +for hosting service operators. + +Retention: 1 year minimum. Cleanup is caller's responsibility. +""" + +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +import aiosqlite + +log = logging.getLogger(__name__) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + user_id TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + event TEXT NOT NULL, + group_id TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log(event); +""" + +EVENTS = { + "connect", + "disconnect", + "handshake", + "file_download", + "file_upload", + "file_delete", + "stream_video", + "chat_message", + "chat_history", + "index_sync", + "auth_failed", +} + +RETENTION_DAYS = 365 + + +@dataclass +class AuditEntry: + id: int + timestamp: float + user_id: str + username: str + ip: str + event: str + group_id: str + detail: str + + +class AuditStore: + """Async SQLite audit log.""" + + def __init__(self, db_path: Path): + self._db_path = db_path + self._db: aiosqlite.Connection | None = None + + async def open(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(str(self._db_path)) + await self._db.executescript(_SCHEMA) + await self._db.commit() + + async def close(self) -> None: + if self._db: + await self._db.close() + self._db = None + + async def log_event( + self, + user_id: str, + event: str, + ip: str = "", + username: str = "", + group_id: str = "", + detail: str = "", + ) -> None: + if not self._db: + return + await self._db.execute( + "INSERT INTO audit_log (timestamp, user_id, username, ip, event, group_id, detail) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (time.time(), user_id, username, ip, event, group_id, detail), + ) + await self._db.commit() + + async def get_entries( + self, + since: float = 0, + limit: int = 200, + user_id: str | None = None, + event: str | None = None, + ) -> list[AuditEntry]: + conditions = ["timestamp > ?"] + params: list = [since] + if user_id: + conditions.append("user_id = ?") + params.append(user_id) + if event: + conditions.append("event = ?") + params.append(event) + params.append(limit) + + where = " AND ".join(conditions) + cursor = await self._db.execute( + f"SELECT id, timestamp, user_id, username, ip, event, group_id, detail " + f"FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ?", + params, + ) + rows = await cursor.fetchall() + return [ + AuditEntry( + id=r[0], timestamp=r[1], user_id=r[2], username=r[3], + ip=r[4], event=r[5], group_id=r[6], detail=r[7], + ) + for r in rows + ] + + async def entry_count(self) -> int: + if not self._db: + return 0 + cursor = await self._db.execute("SELECT COUNT(*) FROM audit_log") + row = await cursor.fetchone() + return row[0] + + async def cleanup(self, retention_days: int = RETENTION_DAYS) -> int: + """Delete entries older than retention_days. Returns count deleted.""" + if not self._db: + return 0 + cutoff = time.time() - (retention_days * 86400) + cursor = await self._db.execute( + "DELETE FROM audit_log WHERE timestamp < ?", (cutoff,)) + await self._db.commit() + return cursor.rowcount diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 5b6e770..5851b34 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -33,6 +33,7 @@ import uvicorn from meshbay_common import MNP_VERSION from meshbay_common.protocol import MNP +from meshbay_node.audit import AuditStore from meshbay_node.chat.store import ChatStore from meshbay_node.config import Config, load_config, write_example_config from meshbay_node.hub_client import HubClient, HubConfig @@ -109,6 +110,7 @@ class NodeDaemon: self._webrtc = None self._denylist = Denylist() if Denylist else None self._chat_stores: dict[str, ChatStore] = {} + self._audit_store: AuditStore | None = None self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] self._hub: HubClient | None = None @@ -192,6 +194,12 @@ class NodeDaemon: groups_ctx[gid]["chat_store"] = store log.info("Chat stores opened: %d groups", len(self._chat_stores)) + # 4b. Audit store (legal compliance — IP + action logging) + audit_db = data_dir / "audit.db" + self._audit_store = AuditStore(db_path=audit_db) + await self._audit_store.open() + log.info("Audit store opened: %s", audit_db) + # 5. Denylist denylist = self._denylist @@ -210,6 +218,7 @@ class NodeDaemon: self._webrtc._ctx["chat_store"] = first.get("chat_store") self._webrtc._ctx["hub_ws"] = _WsSender(hub) self._webrtc._ctx["node_user_id"] = session.user_id + self._webrtc._ctx["audit_store"] = self._audit_store log.info("WebRTC transport ready") else: log.warning("WebRTC not available (aiortc not installed)") @@ -315,6 +324,11 @@ class NodeDaemon: group_cfg.http_port, group_cfg.name) # 10. Local web UI + self._state["groups_ctx"] = groups_ctx + self._state["config"] = self._config + self._state["audit_store"] = self._audit_store + self._state["webrtc"] = self._webrtc + self._state["hub"] = hub from meshbay_node.ui import create_ui_app ui_app = create_ui_app(self._state) ui_cfg = uvicorn.Config( @@ -412,6 +426,9 @@ class NodeDaemon: if self._webrtc: await self._webrtc.close_all() + if self._audit_store: + await self._audit_store.close() + for store in self._chat_stores.values(): await store.close() diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index ab95550..e692c80 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -124,16 +124,35 @@ class _DataChannelBuffer: yield msgpack.unpackb(msg_bytes, raw=False) +def _get_remote_ip(pc: RTCPeerConnection) -> str: + """Best-effort extraction of the remote peer IP from the ICE transport.""" + try: + dtls = pc.sctp and pc.sctp.transport + ice = dtls and dtls.transport + conn = ice and ice._connection + if conn and hasattr(conn, '_nominated') and conn._nominated: + for pair in conn._nominated.values(): + return pair.remote_candidate.host + if conn and conn.remote_candidates: + return conn.remote_candidates[0].host + except Exception: + pass + return "" + + class WebRTCPeerSession: """One WebRTC peer connection, handling MNP over a DataChannel.""" - def __init__(self, pc: RTCPeerConnection, node_ctx: dict): + def __init__(self, pc: RTCPeerConnection, node_ctx: dict, peer_id: str = ""): self._pc = pc self._ctx = node_ctx self._channel: RTCDataChannel | None = None self._buffer = _DataChannelBuffer() self._user_id: str | None = None self._group_id: str | None = None + self._peer_id: str = peer_id + self._remote_ip: str = "" + self._username: str = "" def _setup_channel(self, channel: RTCDataChannel) -> None: self._channel = channel @@ -178,6 +197,20 @@ class WebRTCPeerSession: log.error("Error handling %s on DataChannel: %s", mtype, e) self._send({"type": "error", "detail": str(e)}) + def _audit(self, event: str, detail: str = "") -> None: + audit = self._ctx.get("audit_store") + if audit and self._user_id: + if not self._remote_ip: + self._remote_ip = _get_remote_ip(self._pc) + asyncio.ensure_future(audit.log_event( + user_id=self._user_id, + event=event, + ip=self._remote_ip, + username=self._username, + group_id=self._group_id or "", + detail=detail, + )) + def _do_handshake(self, msg: dict) -> None: token = msg.get("token", "") group_id = msg.get("group_id", "") @@ -185,6 +218,7 @@ class WebRTCPeerSession: decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"]) except Exception as e: self._send({"type": "error", "detail": f"Invalid JWT: {e}"}) + self._audit_auth_failed(group_id, str(e)) return denylist = self._ctx.get("denylist") @@ -202,6 +236,7 @@ class WebRTCPeerSession: self._user_id = decoded["sub"] self._group_id = group_id + self._username = decoded.get("username", "") peers = self._ctx.get("_peers") if peers is not None: @@ -214,6 +249,19 @@ class WebRTCPeerSession: "v": MNP_VERSION, "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), }) + self._audit("handshake") + + def _audit_auth_failed(self, group_id: str, reason: str) -> None: + audit = self._ctx.get("audit_store") + if audit: + self._remote_ip = _get_remote_ip(self._pc) + asyncio.ensure_future(audit.log_event( + user_id="unknown", + event="auth_failed", + ip=self._remote_ip, + group_id=group_id, + detail=reason, + )) def _group_ctx(self) -> dict: if "groups" in self._ctx and self._group_id: @@ -274,6 +322,8 @@ class WebRTCPeerSession: file_hash, ) self._send(chunk_data) + if chunk_index == 0: + self._audit("file_download", entry.name) def _do_stream_segment(self, msg: dict) -> None: ctx = self._group_ctx() @@ -365,6 +415,7 @@ class WebRTCPeerSession: pass self._send({"type": "ack", "v": MNP_VERSION}) + self._audit("chat_message") def _do_chat_history(self, msg: dict) -> None: chat_store = self._ctx.get("chat_store") @@ -441,6 +492,7 @@ class WebRTCPeerSession: final_path = shared_root / safe_name tmp_path.rename(final_path) log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks) + self._audit("file_upload", safe_name) def _do_file_delete(self, msg: dict) -> None: ctx = self._group_ctx() @@ -463,6 +515,7 @@ class WebRTCPeerSession: if file_path.exists(): file_path.unlink() log.info("File deleted: %s", entry.name) + self._audit("file_delete", entry.name) ctx["index"].remove_entry(file_id) self._send({ @@ -549,6 +602,7 @@ class WebRTCPeerSession: "file_id": file_id, }) log.info("Streamed %s: %d segments", entry.name, index) + self._audit("stream_video", entry.name) def _send(self, obj: dict) -> None: if self._channel and self._channel.readyState == "open": @@ -558,6 +612,7 @@ class WebRTCPeerSession: self._channel.readyState if self._channel else "none") async def close(self) -> None: + self._audit("disconnect") peers = self._ctx.get("_peers") if peers and self._user_id: peers.pop(self._user_id, None) @@ -639,7 +694,7 @@ class WebRTCTransport: iceServers=[RTCIceServer(urls=s) for s in self._stun] if self._stun else [] ) pc = RTCPeerConnection(configuration=config) - session = WebRTCPeerSession(pc, self._ctx) + session = WebRTCPeerSession(pc, self._ctx, peer_id=peer_id) self._sessions[peer_id] = session @pc.on("datachannel") 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>""" diff --git a/packages/meshbay-node/tests/test_audit.py b/packages/meshbay-node/tests/test_audit.py new file mode 100644 index 0000000..b2ed15e --- /dev/null +++ b/packages/meshbay-node/tests/test_audit.py @@ -0,0 +1,76 @@ +"""Tests for the node audit store (legal compliance IP/action logging).""" + +import time + +import pytest + +from meshbay_node.audit import AuditStore + + +@pytest.fixture +async def audit(tmp_path): + store = AuditStore(db_path=tmp_path / "audit.db") + await store.open() + yield store + await store.close() + + +@pytest.mark.asyncio +async def test_log_and_retrieve(audit): + await audit.log_event( + user_id="u1", event="handshake", ip="1.2.3.4", + username="alice", group_id="g1", detail="test") + await audit.log_event( + user_id="u2", event="file_download", ip="5.6.7.8", + username="bob", group_id="g1", detail="video.mp4") + + entries = await audit.get_entries() + assert len(entries) == 2 + assert entries[0].event == "file_download" + assert entries[0].ip == "5.6.7.8" + assert entries[1].event == "handshake" + + +@pytest.mark.asyncio +async def test_filter_by_event(audit): + await audit.log_event(user_id="u1", event="handshake", ip="1.1.1.1") + await audit.log_event(user_id="u1", event="file_download", ip="1.1.1.1") + await audit.log_event(user_id="u1", event="handshake", ip="1.1.1.1") + + entries = await audit.get_entries(event="handshake") + assert len(entries) == 2 + assert all(e.event == "handshake" for e in entries) + + +@pytest.mark.asyncio +async def test_filter_by_user(audit): + await audit.log_event(user_id="u1", event="handshake") + await audit.log_event(user_id="u2", event="handshake") + + entries = await audit.get_entries(user_id="u1") + assert len(entries) == 1 + assert entries[0].user_id == "u1" + + +@pytest.mark.asyncio +async def test_entry_count(audit): + assert await audit.entry_count() == 0 + await audit.log_event(user_id="u1", event="connect") + await audit.log_event(user_id="u2", event="connect") + assert await audit.entry_count() == 2 + + +@pytest.mark.asyncio +async def test_cleanup_old_entries(audit): + await audit.log_event(user_id="u1", event="old") + # manually backdate + await audit._db.execute( + "UPDATE audit_log SET timestamp = ? WHERE event = 'old'", + (time.time() - 400 * 86400,)) + await audit._db.commit() + + await audit.log_event(user_id="u2", event="recent") + + deleted = await audit.cleanup(retention_days=365) + assert deleted == 1 + assert await audit.entry_count() == 1 |