#!/usr/bin/env python3
"""sandbroker-dashboard - a read-only local management dashboard for sandbroker.

Security model (this is a control plane for a secret broker, so it matters):
  * Runs UNPRIVILEGED (as gray, in the claude-broker group) -- never as the
    token-holding sandbroker uid. A bug here cannot reach a token.
  * Never touches the filesystem state directly. It gets everything through the
    daemon's read-only `status` action over the socket, which returns only
    non-secret metadata. Secret VALUES are never available to it.
  * Binds 127.0.0.1 only. The srt sandbox has its own netns, so a sandboxed
    agent cannot reach it; and `status`/`lock` are direct-socket-only, so the
    agent could not drive the daemon through it even if it could.
  * A per-launch bearer token gates every request (served in the printed URL),
    so another local process can't silently read state or hit the kill-switch.

Launch (from wsl.sh, via sg claude-broker so it has group access to the socket):
    sandbroker-dashboard        # prints http://127.0.0.1:8765/?t=<token>

The launch URL (with its per-launch bearer token) is also written 0600 to
$XDG_STATE_HOME/sandbroker-dashboard.url (default ~/.local/state/...), so it is
retrievable after a background/setsid launch whose stderr goes to /dev/null:
    cat ~/.local/state/sandbroker-dashboard.url
"""
import json
import os
import secrets
import socket
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs

SOCKET_PATH = os.environ.get("SANDBROKER_SOCKET") or os.path.join(os.environ.get("SANDBROKER_BASE", "/opt/sandbroker"), "run", "sandbroker.sock")
PORT = int(os.environ.get("SANDBROKER_DASHBOARD_PORT", "8765"))
HOST = "127.0.0.1"
DASH_DIR = Path(__file__).resolve().parent.parent / "dashboard"
INDEX = DASH_DIR / "index.html"
SW = DASH_DIR / "sw.js"
TOKEN = secrets.token_urlsafe(24)

# Web Push wiring. The VAPID public key (application server key) is written by
# sandbroker-vapid-init to <base>/etc/vapid_public.txt -- we serve it verbatim, so
# this stays stdlib-only (no cryptography). Browser PushSubscription JSON is
# stored in a gray-writable state dir (NOT under the sandbroker-owned prod base)
# where sandbroker-pushd reads it; both processes run as gray.
BASE = os.environ.get("SANDBROKER_BASE", "/opt/sandbroker")
VAPID_PUBLIC_FILE = os.path.join(BASE, "etc", "vapid_public.txt")
PUSH_STATE_DIR = os.environ.get(
    "SANDBROKER_PUSH_STATE",
    os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
                 "sandbroker-push"))
SUBS_FILE = os.path.join(PUSH_STATE_DIR, "subscriptions.json")
# WebAuthn enrollment courier spool (setgid claude-broker). The dashboard is only
# a file courier between the browser ceremony and the host-only enroll CLI, which
# is the enrollment authority; the dashboard never verifies or stores credentials.
ENROLL_SPOOL = os.path.join(BASE, "run", "authz", "enroll")
ENROLL_CHALLENGE = os.path.join(ENROLL_SPOOL, "challenge.json")
ENROLL_ATTESTATION = os.path.join(ENROLL_SPOOL, "attestation.json")
# Where the launch URL (with its bearer token) is dropped so it is retrievable
# after a background/setsid launch, whose stderr goes to /dev/null. It is a
# 127.0.0.1-only access token for a read-only console -- never a secret VALUE --
# but still written 0600 and overwritten each launch.
URL_FILE = os.environ.get(
    "SANDBROKER_DASHBOARD_URL_FILE",
    os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
                 "sandbroker-dashboard.url"))


def daemon(req: dict) -> dict:
    """One request to the daemon over the direct socket (needs claude-broker)."""
    try:
        conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        conn.settimeout(10)
        conn.connect(SOCKET_PATH)
        conn.sendall((json.dumps(req) + "\n").encode())
        buf = b""
        while b"\n" not in buf:
            chunk = conn.recv(65536)
            if not chunk:
                break
            buf += chunk
        conn.close()
        return json.loads(buf.decode("utf-8", "replace").strip() or "{}")
    except (OSError, ValueError) as exc:
        return {"ok": False, "error": "daemon_unreachable", "detail": str(exc)}


def vapid_public_key() -> str:
    """The application server key (base64url) written by sandbroker-vapid-init."""
    try:
        with open(VAPID_PUBLIC_FILE) as fh:
            return fh.read().strip()
    except OSError:
        return ""


def store_subscription(sub: dict) -> bool:
    """Persist a browser PushSubscription (keyed by endpoint) 0600 where
    sandbroker-pushd reads it. Same-uid (gray) as pushd, so 0600 is enough."""
    endpoint = sub.get("endpoint")
    if not isinstance(endpoint, str) or not endpoint.startswith("http"):
        return False
    os.makedirs(PUSH_STATE_DIR, exist_ok=True)
    try:
        with open(SUBS_FILE) as fh:
            subs = json.load(fh)
        if not isinstance(subs, dict):
            subs = {}
    except (OSError, ValueError):
        subs = {}
    subs[endpoint] = sub
    tmp = SUBS_FILE + ".tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as fh:
        json.dump(subs, fh)
    os.replace(tmp, SUBS_FILE)
    return True


class Handler(BaseHTTPRequestHandler):
    def _authed(self, q):
        # constant-time compare of the ?t= token
        t = (q.get("t") or [""])[0]
        return secrets.compare_digest(t, TOKEN)

    def _json(self, obj, code=200):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        u = urlparse(self.path)
        q = parse_qs(u.query)
        # The service worker is fetched by the browser (including on its own
        # background update checks, which don't carry our token), so it is served
        # WITHOUT the bearer gate. It holds no secret -- just push-display logic.
        if u.path == "/sw.js":
            try:
                body = SW.read_bytes()
            except OSError:
                self.send_error(500, "service worker missing")
                return
            self.send_response(200)
            self.send_header("Content-Type", "application/javascript")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            # Scope must cover "/"; served from the root path, so default scope is "/".
            self.send_header("Service-Worker-Allowed", "/")
            self.end_headers()
            self.wfile.write(body)
            return
        # webauthn.js, like sw.js, is fetched by the browser without our token and
        # holds no secret (just the ceremony wiring), so it is served ungated.
        if u.path == "/webauthn.js":
            try:
                body = (DASH_DIR / "webauthn.js").read_bytes()
            except OSError:
                self.send_error(500, "webauthn.js missing")
                return
            self.send_response(200)
            self.send_header("Content-Type", "application/javascript")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(body)
            return
        if not self._authed(q):
            self._json({"ok": False, "error": "unauthorized"}, 403)
            return
        if u.path == "/api/vapid-public-key":
            self._json({"ok": True, "key": vapid_public_key()})
            return
        if u.path == "/":
            try:
                html = INDEX.read_text(encoding="utf-8").replace("{{TOKEN}}", TOKEN)
            except OSError:
                self.send_error(500, "dashboard html missing")
                return
            body = html.encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Content-Security-Policy", "default-src 'self' 'unsafe-inline'")
            self.end_headers()
            self.wfile.write(body)
        elif u.path == "/api/status":
            self._json(daemon({"action": "status"}))
        elif u.path == "/api/webauthn/challenge":
            # Request-bound challenge for the pending approval. Unwrap the daemon's
            # status.webauthn envelope for the browser ceremony.
            sid = (q.get("sid") or [""])[0]
            r = daemon({"action": "webauthn_challenge", "sid": sid})
            wa = (r.get("status") or {}).get("webauthn") if r.get("ok") else None
            self._json({"ok": True, **wa} if wa else
                       {"ok": False, "error": r.get("error", "no_pending")})
        elif u.path == "/api/webauthn/enroll/challenge":
            # Return the open enrollment window written by the host-only CLI.
            try:
                with open(ENROLL_CHALLENGE) as fh:
                    w = json.load(fh)
            except (OSError, ValueError):
                w = None
            if w and w.get("expires", 0) >= time.time():
                self._json({"ok": True, **w})
            else:
                self._json({"ok": False, "error": "no_window"})
        else:
            self._json({"ok": False, "error": "not_found"}, 404)

    def do_POST(self):
        u = urlparse(self.path)
        q = parse_qs(u.query)
        if not self._authed(q):
            self._json({"ok": False, "error": "unauthorized"}, 403)
            return
        if u.path == "/api/lock":
            self._json(daemon({"action": "lock"}))
        elif u.path == "/api/set-retention":
            try:
                days = int((q.get("days") or ["0"])[0])
            except (ValueError, TypeError):
                days = 0
            # The daemon validates the range (1..3650) and prunes; we just proxy.
            self._json(daemon({"action": "set_retention", "days": days}))
        elif u.path == "/api/subscribe":
            try:
                n = int(self.headers.get("Content-Length") or 0)
                sub = json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
            except (ValueError, OSError):
                self._json({"ok": False, "error": "bad_json"}, 400)
                return
            if isinstance(sub, dict) and store_subscription(sub):
                self._json({"ok": True})
            else:
                self._json({"ok": False, "error": "bad_subscription"}, 400)
        elif u.path == "/api/webauthn/verify":
            # Relay the assertion verbatim to the daemon (direct-socket-only action).
            try:
                n = int(self.headers.get("Content-Length") or 0)
                body = json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
            except (ValueError, OSError):
                self._json({"ok": False, "error": "bad_json"}, 400)
                return
            req = {"action": "webauthn_verify"}
            for k in ("sid", "id", "rawId", "clientDataJSON",
                      "authenticatorData", "signature", "userHandle"):
                if k in body:
                    req[k] = body[k]
            r = daemon(req)
            self._json({"ok": bool(r.get("ok")),
                        "error": ((r.get("status") or {}).get("webauthn") or {}).get("reason")
                                 or r.get("error")})
        elif u.path == "/api/webauthn/enroll/attestation":
            # Drop the browser attestation for the host-only CLI to verify + store.
            try:
                n = int(self.headers.get("Content-Length") or 0)
                body = json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
            except (ValueError, OSError):
                self._json({"ok": False, "error": "bad_json"}, 400)
                return
            if not all(k in body for k in ("clientDataJSON", "attestationObject", "rawId")):
                self._json({"ok": False, "error": "bad_attestation"}, 400)
                return
            os.makedirs(ENROLL_SPOOL, exist_ok=True)
            fd = os.open(ENROLL_ATTESTATION + ".tmp",
                         os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o660)
            with os.fdopen(fd, "w") as fh:
                json.dump(body, fh)
            os.replace(ENROLL_ATTESTATION + ".tmp", ENROLL_ATTESTATION)
            self._json({"ok": True})
        else:
            self._json({"ok": False, "error": "not_found"}, 404)

    def log_message(self, *a):  # keep stdout clean; the URL line is enough
        pass


def main() -> int:
    try:
        httpd = ThreadingHTTPServer((HOST, PORT), Handler)
    except OSError as exc:
        sys.stderr.write("sandbroker-dashboard: cannot bind %s:%d: %s\n" % (HOST, PORT, exc))
        return 1
    url = "http://%s:%d/?t=%s" % (HOST, PORT, TOKEN)
    sys.stderr.write("sandbroker dashboard: %s\n" % url)
    sys.stderr.flush()
    # Drop the URL where a human can find it after a stderr-to-/dev/null launch.
    try:
        os.makedirs(os.path.dirname(URL_FILE), exist_ok=True)
        fd = os.open(URL_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as fh:
            fh.write(url + "\n")
    except OSError:
        pass
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
