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/audit.py | 148 ++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/audit.py (limited to 'packages/meshbay-node/src/meshbay_node/audit.py') 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 -- cgit v1.2.3