"""python -m msgboard {init,serve,backup,rotate-operator-token,demo}""" from __future__ import annotations import argparse import ipaddress import os import signal import sys import time from pathlib import Path from . import db as dbmod from . import store from .config import DB_NAME, DEFAULT_PORT, OPERATOR_TOKEN_FILE NETWORK_FS = {"nfs", "nfs4", "cifs", "smb3", "fuse", "fuseblk", "9p", "sshfs"} def _fs_type(path: Path) -> str | None: """Best-effort filesystem type of `path` from /proc/mounts (Linux only).""" try: mounts = Path("/proc/mounts").read_text().splitlines() except OSError: return None best, fstype = "", None target = str(path.resolve()) for line in mounts: parts = line.split() if len(parts) >= 3 and (target == parts[1] or target.startswith(parts[1].rstrip("/") + "/")): if len(parts[1]) > len(best): best, fstype = parts[1], parts[2] return fstype def _warn_fs(data_dir: Path) -> None: fstype = _fs_type(data_dir) if fstype and (fstype in NETWORK_FS or fstype.startswith("fuse")): print(f"warning: {data_dir} is on a {fstype} filesystem. SQLite WAL needs a local disk; " "do not keep a live board on network or bucket mounts.", file=sys.stderr) def _write_secret_file(path: Path, secret: str) -> None: """Create a 0600 file that must not already exist, and fsync it.""" fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) try: os.write(fd, (secret + "\n").encode()) os.fsync(fd) finally: os.close(fd) def cmd_init(args) -> int: data_dir = Path(args.data_dir).expanduser() data_dir.mkdir(parents=True, exist_ok=True) os.chmod(data_dir, 0o700) _warn_fs(data_dir) db_path = data_dir / DB_NAME token_path = data_dir / OPERATOR_TOKEN_FILE existed = db_path.exists() conn = dbmod.connect(db_path) try: before = dbmod.schema_version(conn) try: version = dbmod.migrate(conn, db_path) except dbmod.SchemaError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if store.admin_exists(conn): note = "migrated" if before != version else "unchanged" print(f"Board already initialized in {data_dir} (schema v{version}, {note}).") tmp_path = data_dir / (OPERATOR_TOKEN_FILE + ".tmp") if not token_path.exists() and tmp_path.exists(): # An earlier init committed but stopped before moving the file into place. try: store.authenticate(conn, tmp_path.read_text().strip(), time.time()) os.link(tmp_path, token_path) tmp_path.unlink() except Exception: pass if token_path.exists(): print(f"Operator token file: {token_path}") else: print("Operator token file is missing; existing credentials were left untouched. " "Run `python -m msgboard rotate-operator-token --data-dir ...` to issue a new one.") return 0 if token_path.exists(): print(f"error: {token_path} exists but the database has no operator; refusing to overwrite it. " "Move the file away if it is stale.", file=sys.stderr) return 1 # Write the secret to a temp file first, then commit, then move it into place. tmp_path = data_dir / (OPERATOR_TOKEN_FILE + ".tmp") if tmp_path.exists(): tmp_path.unlink() conn.execute("BEGIN IMMEDIATE") try: _, token = store.create_operator(conn, time.time()) _write_secret_file(tmp_path, token) conn.execute("COMMIT") except BaseException: conn.execute("ROLLBACK") if tmp_path.exists(): tmp_path.unlink() raise os.link(tmp_path, token_path) # fails rather than overwrite tmp_path.unlink() finally: conn.close() print(f"{'Initialized schema in existing' if existed else 'Created'} board in {data_dir} (schema v{version}).") print(f"Operator token saved to {token_path} (mode 0600). Keep it secret; open the dashboard with it to create invite links.") return 0 def cmd_rotate_operator(args) -> int: data_dir = Path(args.data_dir).expanduser() db_path = data_dir / DB_NAME token_path = data_dir / OPERATOR_TOKEN_FILE if not db_path.exists(): print(f"error: no board in {data_dir}", file=sys.stderr) return 1 conn = dbmod.connect(db_path) try: if dbmod.schema_version(conn) != dbmod.SCHEMA_VERSION: print("error: run init first to migrate the schema", file=sys.stderr) return 1 new_path = data_dir / (OPERATOR_TOKEN_FILE + ".new") if new_path.exists(): new_path.unlink() conn.execute("BEGIN IMMEDIATE") try: token = store.rotate_operator(conn, time.time()) _write_secret_file(new_path, token) conn.execute("COMMIT") except BaseException: conn.execute("ROLLBACK") if new_path.exists(): new_path.unlink() raise os.replace(new_path, token_path) finally: conn.close() print(f"Operator token rotated; the old one is revoked. New token saved to {token_path}.") return 0 def cmd_backup(args) -> int: db_path = Path(args.data_dir).expanduser() / DB_NAME if not db_path.exists(): print(f"error: no board at {db_path}", file=sys.stderr) return 1 conn = dbmod.connect(db_path) try: dbmod.backup(conn, args.out) finally: conn.close() print(f"Backup written to {args.out}. Attachments live in {db_path.parent / 'blobs'}; copy them too.") return 0 def _is_loopback(host: str) -> bool: if host == "localhost": return True try: return ipaddress.ip_address(host).is_loopback except ValueError: return False def cmd_serve(args) -> int: from .server import AlreadyRunning, Board, NotInitialized data_dir = Path(args.data_dir).expanduser() _warn_fs(data_dir) replicator = None if args.replica: from .replicate import Replicator, restore, store_from_url store = store_from_url(args.replica) if not (data_dir / DB_NAME).exists(): manifest = restore(store, data_dir) print(f"Restored {manifest['snapshot']} from replica." if manifest else "Replica is empty; nothing to restore.", file=sys.stderr) replicator = Replicator(store, data_dir, interval=args.snapshot_interval) try: board = Board(data_dir, replicator=replicator) except (NotInitialized, AlreadyRunning, dbmod.SchemaError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 if not _is_loopback(args.host): print(f"warning: listening on {args.host}. Tokens travel in plain HTTP; " "put HTTPS in front of this outside isolated local development.", file=sys.stderr) run_board(board, args.host, args.port, args.log_level) return 0 def run_board(board, host: str, port: int, log_level: str = "info", app=None) -> None: """Serve `board` in the foreground until SIGINT/SIGTERM, then close it (final snapshot included).""" import uvicorn from .server import create_app class BoardServer(uvicorn.Server): def handle_exit(self, sig, frame): board.begin_shutdown() super().handle_exit(sig, frame) config = uvicorn.Config( app or create_app(board), host=host, port=port, log_level=log_level, timeout_graceful_shutdown=5, server_header=False, ) # uvicorn re-raises the signal it caught after shutting down. With the # default SIGTERM action that kills the process before board.close() runs, # so the final snapshot would be lost. Turn it into a normal exit instead. def _exit(signum, frame): raise SystemExit(0) signal.signal(signal.SIGTERM, _exit) signal.signal(signal.SIGINT, _exit) try: BoardServer(config).run() finally: board.close() print("Board stopped cleanly.", file=sys.stderr) def cmd_restore(args) -> int: from .replicate import restore, store_from_url manifest = restore(store_from_url(args.replica), Path(args.data_dir).expanduser()) if manifest is None: print("error: replica has no snapshot", file=sys.stderr) return 1 print(f"Restored {manifest['snapshot']} ({manifest['size']} bytes) into {args.data_dir}.") return 0 def cmd_demo(args) -> int: from .demo import run_demo return run_demo(args.data_dir, args.port) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="python -m msgboard", description=__doc__) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("init", help="create or migrate a board; never overwrites existing credentials") p.add_argument("--data-dir", required=True) p.set_defaults(func=cmd_init) p = sub.add_parser("serve", help="run the HTTP server in the foreground (Ctrl-C to stop)") p.add_argument("--data-dir", required=True) p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=DEFAULT_PORT) p.add_argument("--log-level", default="info") p.add_argument("--replica", default=None, help="snapshot target: hf://buckets//[/] or a directory; " "an empty data dir is restored from it first") p.add_argument("--snapshot-interval", type=float, default=10.0, help="seconds between snapshots") p.set_defaults(func=cmd_serve) p = sub.add_parser("restore", help="restore an empty data dir from a replica") p.add_argument("--data-dir", required=True) p.add_argument("--replica", required=True) p.set_defaults(func=cmd_restore) p = sub.add_parser("backup", help="write a consistent SQLite snapshot") p.add_argument("--data-dir", required=True) p.add_argument("--out", required=True) p.set_defaults(func=cmd_backup) p = sub.add_parser("rotate-operator-token", help="replace the operator token (local file access required)") p.add_argument("--data-dir", required=True) p.set_defaults(func=cmd_rotate_operator) p = sub.add_parser("demo", help="start a throwaway board and run two agents against it") p.add_argument("--data-dir", default=None, help="defaults to a new temporary directory") p.add_argument("--port", type=int, default=0, help="0 picks a free port") p.set_defaults(func=cmd_demo) args = parser.parse_args(argv) return args.func(args)