#!/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"          # bind address (loopback only)
# The URL we ADVERTISE uses "localhost", not the bind IP: WebAuthn rejects an
# IP-literal rpId, so the console must be OPENED as http://localhost:<port> for
# approvals to work (it still resolves to the 127.0.0.1 bind). Overridable.
DISPLAY_HOST = os.environ.get("SANDBROKER_DASHBOARD_DISPLAY_HOST", "localhost")
DASH_DIR = Path(__file__).resolve().parent.parent / "dashboard"
INDEX = DASH_DIR / "index.html"
LOGIN = DASH_DIR / "login.html"
SW = DASH_DIR / "sw.js"
# Static PWA assets, as a fixed request-path -> (filename, content-type) table.
# Serving them by TABLE LOOKUP rather than by deriving a filename from the request
# means no user-provided value ever reaches a filesystem path here.
PWA_ASSETS = {
    "/manifest.webmanifest": ("manifest.webmanifest", "application/manifest+json"),
    "/icon-192.png": ("icon-192.png", "image/png"),
    "/icon-512.png": ("icon-512.png", "image/png"),
    # `maskable` icons are a separate purpose from `any`: the platform crops them
    # to its own shape, so they carry a safe margin the `any` artwork does not.
    "/icon-maskable-192.png": ("icon-maskable-192.png", "image/png"),
    "/icon-maskable-512.png": ("icon-maskable-512.png", "image/png"),
    "/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"),
}
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"))


# ---------------------------------------------------------------------------
# Console session (the passkey login gate)
#
# The bearer token is a TRANSPORT credential: it stops another local process from
# silently driving the console, but it is a bearer secret readable by anything
# running as this user. Once an approver is enrolled we additionally require a
# WebAuthn assertion to open the console, which proves a HUMAN is present rather
# than a process holding a token.
#
# Sessions live only in this process's memory (never on disk): a restart logs you
# out, which is the correct direction for a control plane. Before the first
# enrollment the gate is inactive -- there is no credential to demand, and
# demanding one would make the console unreachable exactly when you need it to
# enroll.
# ---------------------------------------------------------------------------
SESSION_IDLE_SECONDS = int(os.environ.get("SANDBROKER_CONSOLE_IDLE", "1800"))
_SESSIONS = {}          # session token -> last-used epoch
_ENROLLED_CACHE = {"value": False, "ts": 0.0}
_ENROLLED_TTL = 5.0     # seconds; enrolling must activate the gate promptly


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 gate_active() -> bool:
    """True once an approver is enrolled, i.e. a login CAN be demanded. Cached
    briefly so the gate does not cost a socket round trip on every request, but
    short enough that enrolling activates it almost immediately."""
    now = time.time()
    if now - _ENROLLED_CACHE["ts"] > _ENROLLED_TTL:
        r = daemon({"action": "status"})
        _ENROLLED_CACHE["value"] = bool((r.get("status") or {}).get("approver_enrolled"))
        _ENROLLED_CACHE["ts"] = now
    return _ENROLLED_CACHE["value"]


def new_session() -> str:
    _prune_sessions()
    tok = secrets.token_urlsafe(32)
    _SESSIONS[tok] = time.time()
    return tok


def _prune_sessions() -> None:
    cutoff = time.time() - SESSION_IDLE_SECONDS
    for tok in [t for t, seen in _SESSIONS.items() if seen < cutoff]:
        _SESSIONS.pop(tok, None)


def session_valid(tok: str) -> bool:
    """Constant-time lookup of a live session, refreshing its idle clock."""
    if not tok:
        return False
    _prune_sessions()
    for known in list(_SESSIONS):
        if secrets.compare_digest(tok, known):
            _SESSIONS[known] = time.time()
            return True
    return False


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 _session_token(self):
        """Read the session cookie. HttpOnly, so page JS never handles it."""
        raw = self.headers.get("Cookie") or ""
        for part in raw.split(";"):
            name, _, value = part.strip().partition("=")
            if name == "sb_session":
                return value
        return ""

    def _unlocked(self):
        """True if this request may touch console data: the bearer token always,
        plus a live passkey session once an approver is enrolled."""
        return (not gate_active()) or session_valid(self._session_token())

    def _serve_page(self, src):
        """Serve a console page (the app or the login shell) with the current
        launch token injected, never cached."""
        try:
            html = src.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'")
        # Never cache the console HTML: it is a live control plane with a
        # per-launch token injected, and a stale cached copy silently runs old UI
        # logic (this is exactly what once hid a just-shipped change).
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _set_session_cookie(self, tok):
        # HttpOnly (page JS cannot read it), SameSite=Strict (no cross-site
        # submission), Path=/. Not Secure: the console is plain http on loopback,
        # which browsers already treat as a secure context.
        self.send_header("Set-Cookie",
                         "sb_session=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d"
                         % (tok, SESSION_IDLE_SECONDS))

    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
        # Static PWA assets: no token (the browser fetches them on its own, and an
        # installed app requests the manifest/icons without our query string).
        # They carry no state.
        if u.path in PWA_ASSETS:
            # The filename comes from this constant table, never from the request
            # path: the request only selects a row, so no user-provided value can
            # reach the filesystem call.
            name, ctype = PWA_ASSETS[u.path]
            try:
                body = (DASH_DIR / name).read_bytes()
            except OSError:
                self.send_error(404, "asset missing")
                return
            self.send_response(200)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(body)
            return
        # The PAGE itself may be opened without the per-launch token ONCE a passkey
        # gate is active: an installed PWA's start_url is fixed at install time, so
        # requiring a token that rotates every launch would break the app on the
        # next restart. This is safe only because the passkey session -- not the
        # token -- is the boundary once enrolled: an unauthenticated GET / yields
        # the login shell, and every /api/* still demands the session. BEFORE
        # enrollment the token is the only gate, so it stays mandatory.
        if u.path == "/" and not self._authed(q) and gate_active() and not self._unlocked():
            self._serve_page(LOGIN)
            return
        if not self._authed(q):
            self._json({"ok": False, "error": "unauthorized"}, 403)
            return
        # Login endpoints are reachable with the token alone -- they are how you
        # obtain a session in the first place. Everything else needs the session
        # once an approver is enrolled.
        if u.path == "/api/login/challenge":
            r = daemon({"action": "login_challenge"})
            wa = (r.get("status") or {}).get("login_webauthn") if r.get("ok") else None
            self._json({"ok": True, **wa} if wa else
                       {"ok": False, "error": r.get("error", "no_challenge")})
            return
        if u.path == "/api/login/state":
            self._json({"ok": True, "gate": gate_active(),
                        "unlocked": self._unlocked()})
            return
        if not self._unlocked() and u.path.startswith("/api/"):
            self._json({"ok": False, "error": "locked"}, 401)
            return
        if u.path == "/api/devices":
            r = daemon({"action": "devices"})
            self._json({"ok": True, "devices": ((r.get("status") or {}).get("devices") or [])}
                       if r.get("ok") else {"ok": False, "error": r.get("error", "failed")})
            return
        if u.path == "/api/vapid-public-key":
            self._json({"ok": True, "key": vapid_public_key()})
            return
        if u.path == "/":
            # Locked (an approver is enrolled and this browser has no live
            # session) -> serve the login shell instead of the console. The
            # console HTML holds no data of its own, but sending the operator to
            # a page whose every API call 401s is a worse experience than asking
            # for the passkey up front.
            self._serve_page(INDEX if self._unlocked() else LOGIN)
        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"})
        elif u.path == "/api/admin/challenge":
            # Challenge for a posture-reducing admin action, bound to its exact
            # parameters by the daemon. Each action carries its own parameter, and
            # the daemon validates every one of them.
            act = (q.get("a") or [""])[0]
            req = {"action": "admin_challenge", "admin_action": act}
            if act == "set_retention":
                try:
                    req["days"] = int((q.get("days") or ["0"])[0])
                except (ValueError, TypeError):
                    req["days"] = 0
            elif act == "device_revoke":
                req["credential_id"] = (q.get("credential_id") or [""])[0]
            r = daemon(req)
            wa = (r.get("status") or {}).get("admin_webauthn") if r.get("ok") else None
            self._json({"ok": True, **wa} if wa else
                       {"ok": False, "error": r.get("error", "no_challenge")})
        elif u.path == "/api/proposal/challenge":
            # Request-bound WebAuthn challenge for approving a capability
            # proposal. Unwrap the daemon's status.proposal_webauthn envelope for
            # the browser ceremony (same shape as the invoke-approval challenge).
            name = (q.get("name") or [""])[0]
            # `d` is the digest prefix the card displayed; the daemon refuses to
            # challenge if the spooled artifacts changed since (swap protection).
            digest = (q.get("d") or [""])[0]
            req = {"action": "proposal_challenge", "name": name}
            if digest:
                req["digest"] = digest
            r = daemon(req)
            wa = (r.get("status") or {}).get("proposal_webauthn") if r.get("ok") else None
            self._json({"ok": True, **wa} if wa else
                       {"ok": False, "error": r.get("error", "no_proposal")})
        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/login/verify":
            # Relay the assertion to the daemon (which owns the credential store);
            # on success mint a session HERE, so the daemon issues no durable
            # credential of its own.
            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": "login_verify"}
            for k in ("id", "rawId", "clientDataJSON",
                      "authenticatorData", "signature", "userHandle"):
                if k in body:
                    req[k] = body[k]
            r = daemon(req)
            if not r.get("ok"):
                # Prefer the daemon's specific reason (e.g. stale_challenge) over
                # the generic token, so the login page can recover instead of just
                # reporting a dead end.
                reason = ((r.get("status") or {}).get("login_webauthn") or {}).get("reason")
                self._json({"ok": False, "error": reason or r.get("error", "denied")}, 403)
                return
            who = (r.get("status") or {}).get("login_webauthn") or {}
            tok = new_session()
            payload = json.dumps({"ok": True, "device": who.get("device", ""),
                                  "operator": who.get("operator", "")}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.send_header("Cache-Control", "no-store")
            self._set_session_cookie(tok)
            self.end_headers()
            self.wfile.write(payload)
            return
        if u.path == "/api/client-report":
            # Browser-side ceremony failures never reach the daemon on their own,
            # so relay them into the audit trail. The daemon accepts only an
            # allowlisted token, so this cannot write arbitrary content.
            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):
                body = {}
            self._json(daemon({"action": "client_report",
                               "what": (body or {}).get("what"),
                               "detail": (body or {}).get("detail")}))
            return
        if u.path == "/api/login/logout":
            _SESSIONS.pop(self._session_token(), None)
            self._json({"ok": True})
            return
        if not self._unlocked():
            self._json({"ok": False, "error": "locked"}, 401)
            return
        if u.path == "/api/device-revoke":
            # Relay the credential id + the WebAuthn assertion; the daemon
            # enforces both gates (a current approver authorized it, and it is
            # not the last device).
            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": "device_revoke"}
            for k in ("credential_id", "id", "rawId", "clientDataJSON",
                      "authenticatorData", "signature", "userHandle"):
                if isinstance(body, dict) and k in body:
                    req[k] = body[k]
            self._json(daemon(req))
            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
            # Relay any WebAuthn assertion fields the browser supplied: once an
            # approver is enrolled the daemon REQUIRES one for this action (it
            # prunes the audit log), bound to this exact `days` value.
            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):
                body = {}
            req = {"action": "set_retention", "days": days}
            if isinstance(body, dict):
                for k in ("id", "rawId", "clientDataJSON",
                          "authenticatorData", "signature", "userHandle"):
                    if k in body:
                        req[k] = body[k]
            # The daemon validates the range (1..3650), enforces the assertion, and
            # prunes; we just proxy.
            self._json(daemon(req))
        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})
        elif u.path == "/api/proposal/approve":
            # Relay a WebAuthn assertion approving a capability proposal to the
            # daemon (direct-socket-only action). The daemon binds the assertion
            # to the proposal's digest and marks it APPROVED for the installer.
            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": "proposal_approve"}
            for k in ("name", "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("proposal_webauthn") or {}).get("reason")
                                 or r.get("error")})
        elif u.path == "/api/proposal/reject":
            # Discard a proposal (direct-socket-only). No WebAuthn: rejecting
            # grants nothing.
            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
            r = daemon({"action": "proposal_reject", "name": body.get("name")})
            self._json({"ok": bool(r.get("ok")), "error": r.get("error")})
        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" % (DISPLAY_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())
