From b0e93eb3cbbc1b203835d0972837e4778f67bd12 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 05:35:14 +0200 Subject: [PATCH 1/8] feat(proxmox): interactive VM panel via the MCP Apps extension proxmox_vm_panel carries _meta.ui.resourceUri pointing at a ui:// resource served as text/html;profile=mcp-app, which an Apps-capable client renders in a sandboxed iframe: live CPU/RAM/disk, start/stop/restart, and fields for core count and memory. Runs on mcp 1.x. The Apps class that wraps this lives in 2.0 and needs MCPServer, but the two knobs it sets -- meta= on the tool, mime_type= on the resource -- are already on FastMCP, so the panel does not wait on that migration. The panel holds no cluster access of its own. Its buttons issue ordinary tools/call requests for proxmox_vm_start / _stop / _restart / _config, so the client's approval prompt still stands in front of every action. Clients that skipped the extension ignore _meta.ui and get the same snapshot as data, which is why the tool returns the full state rather than a placeholder. Verified against a harness that speaks the host side of the protocol: handshake, initial render, power actions with refresh, config apply sending only changed keys, tool errors surfacing without wedging the controls, and the theme switch. --- docs/tools.md | 22 +- src/beaconmcp/proxmox/panel.py | 105 +++++++++ src/beaconmcp/proxmox/vm_panel.html | 335 ++++++++++++++++++++++++++++ src/beaconmcp/server.py | 2 + tests/test_mcp_apps_panel.py | 163 ++++++++++++++ 5 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 src/beaconmcp/proxmox/panel.py create mode 100644 src/beaconmcp/proxmox/vm_panel.html create mode 100644 tests/test_mcp_apps_panel.py diff --git a/docs/tools.md b/docs/tools.md index 87a3bd5..086cfdd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,6 +1,6 @@ # MCP tools -44 tools across six modules. The infrastructure modules are only registered when the matching +45 tools across six modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from Proxmox or BMC. `security_end_session` is always registered, whatever the topology. @@ -50,6 +50,26 @@ tools below exist for when you already know what you're looking at. | `proxmox_backup_list` | List vzdump archives on a storage pool. | | `proxmox_backup_restore` | Restore from an archive. | +## Proxmox — interactive panel (1) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_panel` | Control panel for one guest: live CPU/RAM/disk, power buttons, CPU and memory fields. | + +This one ships a UI. It uses the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) +(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at +`ui://beaconmcp/vm-panel.html`, a self-contained HTML document served as +`text/html;profile=mcp-app` that the client renders in a sandboxed iframe and talks to over +JSON-RPC on `postMessage`. + +The panel does not reach the cluster itself. Its buttons call the ordinary tools +(`proxmox_vm_start` / `_stop` / `_restart` / `_config`), so whatever approval your client applies +to a tool call applies here too — the panel is a nicer way to issue the call, not a way around +the prompt. + +Clients that did not negotiate the extension ignore `_meta.ui` and show the tool's return value, +which is the same VM snapshot as data. Nothing breaks; you just don't get the frame. + ## Proxmox — system and files (9) | Tool | Description | diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py new file mode 100644 index 0000000..fee7fb7 --- /dev/null +++ b/src/beaconmcp/proxmox/panel.py @@ -0,0 +1,105 @@ +"""MCP Apps panel for a single VM/CT. + +The MCP Apps extension (`io.modelcontextprotocol/ui`) lets a tool carry a +reference to an interactive UI: `_meta.ui.resourceUri` points at a `ui://` +resource served as `text/html;profile=mcp-app`, which the host renders in a +sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The wire format is all this module needs, so it runs on mcp 1.x. The `Apps` +extension class that wraps it lives in mcp 2.0 and requires `MCPServer`; the +two knobs it sets -- `meta=` on the tool and `mime_type=` on the resource -- +are already on `FastMCP`. + +Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's +return value, which is why `proxmox_vm_panel` returns the full snapshot as +data rather than a "see the panel" placeholder. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ProxmoxClient + +PANEL_URI = "ui://beaconmcp/vm-panel.html" +APP_MIME_TYPE = "text/html;profile=mcp-app" + +_PANEL_HTML = Path(__file__).parent / "vm_panel.html" + +_MB = 1048576 +_GB = 1073741824 + + +def _snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: + """Everything the panel renders, in one pass over both guest types.""" + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + if "does not exist" in str(data["error"]).lower(): + continue + return data + if not isinstance(data, dict) or not data.get("status"): + continue + + # QEMU reports disk=0 unless the guest agent is answering, so a zero + # here means "unknown", not "empty". LXC reports it for real. + disk_used = data.get("disk") or 0 + conf = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + if not isinstance(conf, dict) or "error" in conf: + conf = {} + + return { + "node": node, + "vmid": vmid, + "type": vm_type, + "name": data.get("name", ""), + "status": data.get("status"), + "cpu_pct": round(data.get("cpu", 0) * 100, 1), + "cpus": data.get("cpus"), + "mem_used_mb": round(data.get("mem", 0) / _MB), + "mem_max_mb": round(data.get("maxmem", 0) / _MB), + "disk_used_gb": round(disk_used / _GB, 1) if disk_used else None, + "disk_max_gb": round(data.get("maxdisk", 0) / _GB, 1), + "uptime_h": round(data.get("uptime", 0) / 3600, 1), + "cores": conf.get("cores"), + "memory_mb": conf.get("memory"), + } + + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} + + +def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + @mcp.tool( + meta={"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: + """Open an interactive control panel for one VM or container. + + Renders live CPU / RAM / disk state with buttons for start, stop and + restart, and fields to change the CPU core count and memory. Use this + instead of proxmox_vm_status when the user wants to *act* on a guest + rather than just read its numbers, or when they ask to "manage", + "control" or "open" a VM. + + The panel drives the ordinary tools (proxmox_vm_start / _stop / + _restart / _config), so every action it takes goes through the same + approval the client applies to any other tool call. + + Returns: {node, vmid, type, name, status, cpu_pct, cpus, mem_used_mb, + mem_max_mb, disk_used_gb, disk_max_gb, uptime_h, cores, memory_mb}. + ``disk_used_gb`` is null when the guest does not report it. + """ + return _snapshot(client, node, vmid) + + @mcp.resource( + PANEL_URI, + name="vm-panel", + title="VM control panel", + description="Interactive panel for a single Proxmox VM or container.", + mime_type=APP_MIME_TYPE, + ) + def vm_panel_app() -> str: + return _PANEL_HTML.read_text(encoding="utf-8") diff --git a/src/beaconmcp/proxmox/vm_panel.html b/src/beaconmcp/proxmox/vm_panel.html new file mode 100644 index 0000000..a300fee --- /dev/null +++ b/src/beaconmcp/proxmox/vm_panel.html @@ -0,0 +1,335 @@ + + +VM control panel + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+
CPU
+
+
+
+
+
Memory
+
+
+
+
+
Disk
+
+
+
+
+
Uptime
+
+
+
+ +
+
Power
+
+ + + + +
+ +
+ +
Resources
+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 0265178..4503cb0 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -15,6 +15,7 @@ from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools +from .proxmox.panel import register_panel_tools from .proxmox.system import register_system_tools from .proxmox.vms import register_vm_tools from .security.tools import register_security_tools @@ -281,6 +282,7 @@ def beaconmcp_context() -> str: if config.pve_nodes: register_monitoring_tools(mcp, proxmox_client) register_vm_tools(mcp, proxmox_client) + register_panel_tools(mcp, proxmox_client) register_system_tools(mcp, proxmox_client, ssh_client if config.ssh and config.ssh.hosts else None) # Aggregators ride on top of the Proxmox client and opportunistically # pull BMC facts when the registry is non-empty. diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py new file mode 100644 index 0000000..d4bafac --- /dev/null +++ b/tests/test_mcp_apps_panel.py @@ -0,0 +1,163 @@ +"""Tests for the MCP Apps VM panel. + +The value here is the wire format: a host only renders the panel if the tool +carries `_meta.ui.resourceUri` and the resource comes back under the mcp-app +MIME type, so these assert what goes out on the wire rather than the Python +objects behind it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.proxmox.panel import APP_MIME_TYPE, PANEL_URI, _snapshot, register_panel_tools + +_NOT_FOUND = {"error": "Configuration file 'nodes/pve1/qemu/100.conf' does not exist"} + +_QEMU_STATUS = { + "status": "running", + "name": "web-101", + "cpu": 0.1234, + "cpus": 4, + "mem": 2 * 1048576 * 1024, + "maxmem": 4 * 1048576 * 1024, + # QEMU reports 0 unless the guest agent answers. + "disk": 0, + "maxdisk": 32 * 1073741824, + "uptime": 7200, +} + + +class _Client: + """Minimal ProxmoxClient stand-in keyed on the path suffix.""" + + def __init__(self, responses: dict[str, Any]) -> None: + self.responses = responses + self.calls: list[str] = [] + + def get(self, node: str, path: str) -> Any: + self.calls.append(path) + for suffix, value in self.responses.items(): + if path.endswith(suffix): + return value + return _NOT_FOUND + + +def _server(client: Any) -> FastMCP: + mcp = FastMCP("test") + register_panel_tools(mcp, client) + return mcp + + +# --- wire format ------------------------------------------------------------ + + +async def test_tool_points_at_the_panel_resource() -> None: + tools = await _server(_Client({})).list_tools() + panel = next(t for t in tools if t.name == "proxmox_vm_panel") + assert panel.meta == {"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}} + + +async def test_resource_is_served_as_an_mcp_app() -> None: + resources = await _server(_Client({})).list_resources() + panel = next(r for r in resources if str(r.uri) == PANEL_URI) + assert panel.mimeType == APP_MIME_TYPE + + +async def test_advertised_resource_uri_actually_resolves() -> None: + """A tool whose resourceUri 404s on resources/read renders as a blank frame.""" + mcp = _server(_Client({})) + tools = await mcp.list_tools() + uri = next(t for t in tools if t.name == "proxmox_vm_panel").meta["ui"]["resourceUri"] + + contents = list(await mcp.read_resource(uri)) + + assert contents, f"{uri} is advertised but reads back empty" + assert "ui/initialize" in contents[0].content, "panel HTML is missing the host handshake" + + +# --- snapshot mapping ------------------------------------------------------- + + +def test_qemu_disk_usage_is_null_not_zero() -> None: + """0 from QEMU means "no guest agent", which must not render as an empty bar.""" + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap["disk_used_gb"] is None + assert snap["disk_max_gb"] == 32.0 + + +def test_qemu_snapshot_maps_status_and_config() -> None: + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap == { + "node": "pve1", + "vmid": 100, + "type": "qemu", + "name": "web-101", + "status": "running", + "cpu_pct": 12.3, + "cpus": 4, + "mem_used_mb": 2048, + "mem_max_mb": 4096, + "disk_used_gb": None, + "disk_max_gb": 32.0, + "uptime_h": 2.0, + "cores": 4, + "memory_mb": 4096, + } + + +def test_falls_through_to_lxc_when_no_qemu_guest() -> None: + client = _Client({ + "lxc/200/status/current": { + "status": "running", "name": "ct", "cpu": 0, "cpus": 1, + "mem": 0, "maxmem": 536870912, + "disk": 5 * 1073741824, "maxdisk": 10 * 1073741824, "uptime": 0, + }, + "lxc/200/config": {"cores": 1, "memory": 512}, + }) + + snap = _snapshot(client, "pve1", 200) + + assert snap["type"] == "lxc" + assert snap["disk_used_gb"] == 5.0 + + +def test_missing_guest_reports_an_error() -> None: + snap = _snapshot(_Client({}), "pve1", 999) + + assert "not found" in snap["error"] + + +def test_unreadable_config_still_renders_live_state() -> None: + """A config read can fail on its own; the panel should degrade, not blank out.""" + client = _Client({ + "qemu/100/status/current": _QEMU_STATUS, + "qemu/100/config": {"error": "connection refused"}, + }) + + snap = _snapshot(client, "pve1", 100) + + assert snap["status"] == "running" + assert snap["cores"] is None + + +def test_transport_error_is_not_mistaken_for_a_missing_guest() -> None: + """Only a "does not exist" error means "try the other guest type".""" + client = _Client({"qemu/100/status/current": {"error": "connection refused"}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap["error"] == "connection refused" + assert not any("lxc" in call for call in client.calls) From 9f9e987be1bd072331e7c8c524829108aa19503d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 05:55:29 +0200 Subject: [PATCH 2/8] fix(panel): send ui/initialize params flat, as the host expects The handshake nested appCapabilities under a `capabilities` key and sent `appInfo` as `clientInfo`. The real shape, per the ext-apps App.connect() implementation, is flat: appInfo / appCapabilities / protocolVersion. A rejected handshake is silent -- the host simply does not reply. So the promise never settled, `ui/notifications/initialized` never went out, the host never delivered `ui/notifications/tool-result`, and the panel sat on "Loading..." with an empty frame and nothing in the console. That is what showed up in Claude: the host reported the widget as rendered while the iframe stayed blank. Also surface the failure instead of hanging on it. A handshake that goes unanswered for 5s now replaces the spinner with the reason, so the next protocol mismatch is one glance rather than an afternoon. The browser harness this was first tested against replied to any ui/initialize it received, which is why the bad shape passed. It now validates the params like a host does, and the new test pins the flat shape against the shipped HTML -- it fails on the old file. --- src/beaconmcp/proxmox/vm_panel.html | 28 ++++++++++++++++++++++++---- tests/test_mcp_apps_panel.py | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/beaconmcp/proxmox/vm_panel.html b/src/beaconmcp/proxmox/vm_panel.html index a300fee..3bef7a9 100644 --- a/src/beaconmcp/proxmox/vm_panel.html +++ b/src/beaconmcp/proxmox/vm_panel.html @@ -70,7 +70,7 @@ margin-top: 12px; padding: 8px 10px; border-radius: 6px; font-size: 12px; border: 1px solid var(--line); } - .note[data-kind="error"] { color: var(--danger); border-color: currentColor; } + .note[data-kind="error"], #loading[data-kind="error"] { color: var(--danger); border-color: currentColor; } .note[data-kind="ok"] { color: var(--ok); border-color: currentColor; } [hidden] { display: none !important; } @@ -182,6 +182,14 @@

} } + function fail(err) { + const loading = $("loading"); + loading.hidden = false; + loading.textContent = err.message || String(err); + loading.dataset.kind = "error"; + reportSize(); + } + function setNote(text, kind) { const note = $("note"); note.textContent = text || ""; @@ -322,14 +330,26 @@

} }); + // Params are flat: appInfo / appCapabilities / protocolVersion. Nesting the + // capabilities or sending clientInfo instead of appInfo fails the host's + // schema check, and a rejected handshake is silent -- the host simply never + // answers, so the app never sends `initialized` and the host never delivers + // the tool result. See @modelcontextprotocol/ext-apps App.connect(). request("ui/initialize", { + appInfo: { name: "beaconmcp-vm-panel", version: "1.0.0" }, + appCapabilities: { availableDisplayModes: ["inline"] }, protocolVersion: PROTOCOL_VERSION, - capabilities: { appCapabilities: { availableDisplayModes: ["inline"] } }, - clientInfo: { name: "beaconmcp-vm-panel", version: "1.0.0" }, }).then((result) => { applyHostContext(result && result.hostContext); notify("ui/notifications/initialized", {}); reportSize(); - }); + }).catch(fail); + + // Without this, a handshake the host declines to answer leaves the panel on + // "Loading…" forever, which reads as "the server is slow" rather than "this + // app is broken". + setTimeout(() => { + if (!vm) fail(new Error("The host did not complete the ui/initialize handshake.")); + }, 5000); })(); diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py index d4bafac..5975db0 100644 --- a/tests/test_mcp_apps_panel.py +++ b/tests/test_mcp_apps_panel.py @@ -82,6 +82,26 @@ async def test_advertised_resource_uri_actually_resolves() -> None: assert "ui/initialize" in contents[0].content, "panel HTML is missing the host handshake" +async def test_handshake_params_are_flat() -> None: + """ui/initialize takes appInfo / appCapabilities / protocolVersion, flat. + + Nesting them under `capabilities`, or sending `clientInfo` instead of + `appInfo`, fails the host's schema check -- and a rejected handshake is + silent: the host just never replies, so the app never sends `initialized` + and never receives the tool result. The panel then sits on "Loading..." + with nothing in the console to explain it. + """ + mcp = _server(_Client({})) + html = list(await mcp.read_resource(PANEL_URI))[0].content + params = html.split('request("ui/initialize", {', 1)[1].split("})", 1)[0] + + assert "appInfo:" in params + assert "appCapabilities:" in params + assert "protocolVersion:" in params + assert "clientInfo" not in params + assert "capabilities: {" not in params + + # --- snapshot mapping ------------------------------------------------------- From d4877448cf9bb374e9b87874539c1d387239a0f0 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 06:15:20 +0200 Subject: [PATCH 3/8] feat(panels): log viewer, cluster dashboard, and model-context sync Three additions on top of the VM panel. proxmox_logs_panel renders a node's syslog or task history as a scrollable list with level and substring filters, error and warning lines coloured, and a fullscreen request. Logs are the worst thing to read through a chat transcript: the model summarises them and the lines you wanted are gone. cluster_overview_interactive is cluster_overview as a browsable panel -- node cards with CPU and memory pressure, a searchable guest table with inline start/stop, storage pools with usage bars. It reuses the aggregators' collection helpers rather than re-querying Proxmox its own way. Both panels, and now the VM panel, push state back with ui/update-model-context after an action. Without it the model keeps whatever the tool returned when the panel opened, so stopping a VM from the panel left the next turn believing it was still running. Where the host advertises ui/message, the VM panel offers an "ask about this guest" button and the dashboard an "open" button per row; both are hidden when it does not. Three panels meant three copies of the JSON-RPC bridge, so it moves to apps/bridge.js with the shared look in apps/panel.css, spliced in at the marker when the resource is read. Each panel still ships as one self-contained document. Verified in a browser against a harness that validates the handshake the way a host does: rendering, filters, source switching, inline power actions with reload, context updates and messages arriving with the right shapes, and the graceful path when the host advertises neither capability. --- docs/tools.md | 41 +- src/beaconmcp/proxmox/apps/bridge.js | 176 +++++++++ src/beaconmcp/proxmox/apps/cluster_panel.html | 292 ++++++++++++++ src/beaconmcp/proxmox/apps/logs_panel.html | 194 ++++++++++ src/beaconmcp/proxmox/apps/panel.css | 93 +++++ src/beaconmcp/proxmox/apps/vm_panel.html | 215 +++++++++++ src/beaconmcp/proxmox/panel.py | 153 +++++++- src/beaconmcp/proxmox/vm_panel.html | 355 ------------------ tests/test_mcp_apps_panel.py | 162 +++++--- 9 files changed, 1245 insertions(+), 436 deletions(-) create mode 100644 src/beaconmcp/proxmox/apps/bridge.js create mode 100644 src/beaconmcp/proxmox/apps/cluster_panel.html create mode 100644 src/beaconmcp/proxmox/apps/logs_panel.html create mode 100644 src/beaconmcp/proxmox/apps/panel.css create mode 100644 src/beaconmcp/proxmox/apps/vm_panel.html delete mode 100644 src/beaconmcp/proxmox/vm_panel.html diff --git a/docs/tools.md b/docs/tools.md index 086cfdd..f1bd7a8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,6 +1,6 @@ # MCP tools -45 tools across six modules. The infrastructure modules are only registered when the matching +47 tools across six modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from Proxmox or BMC. `security_end_session` is always registered, whatever the topology. @@ -50,25 +50,38 @@ tools below exist for when you already know what you're looking at. | `proxmox_backup_list` | List vzdump archives on a storage pool. | | `proxmox_backup_restore` | Restore from an archive. | -## Proxmox — interactive panel (1) +## Proxmox — interactive panels (3) | Tool | Description | |------|-------------| | `proxmox_vm_panel` | Control panel for one guest: live CPU/RAM/disk, power buttons, CPU and memory fields. | - -This one ships a UI. It uses the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) -(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at -`ui://beaconmcp/vm-panel.html`, a self-contained HTML document served as -`text/html;profile=mcp-app` that the client renders in a sandboxed iframe and talks to over -JSON-RPC on `postMessage`. - -The panel does not reach the cluster itself. Its buttons call the ordinary tools -(`proxmox_vm_start` / `_stop` / `_restart` / `_config`), so whatever approval your client applies -to a tool call applies here too — the panel is a nicer way to issue the call, not a way around -the prompt. +| `proxmox_logs_panel` | Scrollable syslog and task viewer for one node, with level and substring filters. | +| `cluster_overview_interactive` | Cluster dashboard: node pressure, searchable guest table with inline start/stop, storage bars. | + +These three ship a UI. They use the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) +(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at a `ui://` +resource, a self-contained HTML document served as `text/html;profile=mcp-app` that the client +renders in a sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The panels live in `src/beaconmcp/proxmox/apps/`. They share `bridge.js` (the JSON-RPC client) +and `panel.css` (the look), spliced in at the `` marker when the resource is +read, so each one still ships as a single document. + +**They hold no cluster access of their own.** Every button issues an ordinary `tools/call` for +the tools listed elsewhere in this document — `proxmox_vm_start`, `_stop`, `_restart`, `_config` +— so whatever approval your client puts in front of a tool call applies here too. A panel is a +nicer way to issue the call, not a way around the prompt. + +After an action, a panel pushes the fresh state back into the conversation with +`ui/update-model-context`. Without it the model keeps whatever the tool returned when the panel +opened: stop a VM from the panel and the next turn still thinks it is running, because the +button's result goes to the iframe, not to the model. Where the host supports `ui/message`, the +VM panel also offers a button that asks the model to investigate the guest, and the cluster +dashboard one that asks it to open a specific VM's panel. Both features are gated on the host +advertising the capability and are simply hidden when it does not. Clients that did not negotiate the extension ignore `_meta.ui` and show the tool's return value, -which is the same VM snapshot as data. Nothing breaks; you just don't get the frame. +which is the same snapshot as data. Nothing breaks; you just don't get the frame. ## Proxmox — system and files (9) diff --git a/src/beaconmcp/proxmox/apps/bridge.js b/src/beaconmcp/proxmox/apps/bridge.js new file mode 100644 index 0000000..008d6c1 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/bridge.js @@ -0,0 +1,176 @@ +// Shared MCP Apps client bridge. +// +// JSON-RPC 2.0 over postMessage to the host, per the ext-apps spec. Injected +// into each ui:// document by panel.py in place of the +// marker, so the panels stay single self-contained resources. +// +// Deliberately small and dependency-free: an app resource is preloaded by the +// host before the tool even runs, so every kilobyte is paid up front. + +const MCPApp = (() => { + "use strict"; + + const PROTOCOL_VERSION = "2026-01-26"; + + let nextId = 0; + const pending = new Map(); + let hostCapabilities = {}; + let onToolResult = null; + let onHostContext = null; + let ready = false; + + function post(message) { + window.parent.postMessage(message, "*"); + } + + function request(method, params) { + const id = ++nextId; + post({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + } + + function notify(method, params) { + post({ jsonrpc: "2.0", method, params }); + } + + // The host owns the iframe height, so it has to be told when the content + // reflows -- otherwise the panel renders into a fixed sliver. + function reportSize() { + notify("ui/notifications/size-changed", { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }); + } + + // A CallToolResult carries the dict in structuredContent, but a host that + // strips it still sends the JSON as text -- fall back rather than blank out. + function unwrap(result) { + if (result && result.structuredContent) return result.structuredContent; + const first = result && result.content && result.content[0]; + if (first && first.type === "text") { + try { return JSON.parse(first.text); } catch { return null; } + } + return null; + } + + async function callTool(name, args) { + const result = await request("tools/call", { name, arguments: args }); + const data = unwrap(result); + if (result && result.isError) { + throw new Error((data && data.error) || "tool call failed"); + } + if (data && data.error) throw new Error(data.error); + return data; + } + + // Both of these are gated on host capabilities. Calling one the host did not + // advertise gets an error back, so check first and no-op quietly: a panel + // that works everywhere beats one that throws on a stricter host. + function updateModelContext(structuredContent, text) { + if (!hostCapabilities.updateModelContext) return Promise.resolve(false); + return request("ui/update-model-context", { + content: text ? [{ type: "text", text }] : undefined, + structuredContent, + }).then(() => true, () => false); + } + + function sendMessage(text) { + if (!hostCapabilities.message) return Promise.resolve(false); + return request("ui/message", { + role: "user", + content: [{ type: "text", text }], + }).then((r) => !(r && r.isError), () => false); + } + + function requestDisplayMode(mode) { + return request("ui/request-display-mode", { mode }).then( + (r) => (r && r.mode) || null, + () => null, + ); + } + + window.addEventListener("message", (event) => { + const message = event.data; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id != null && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message || "request failed")); + else resolve(message.result); + return; + } + + switch (message.method) { + case "ui/notifications/tool-result": + if (onToolResult) onToolResult(unwrap(message.params)); + break; + case "ui/notifications/host-context-changed": + if (onHostContext) onHostContext(message.params); + break; + case "ui/resource-teardown": + post({ jsonrpc: "2.0", id: message.id, result: {} }); + break; + } + }); + + function applyHostContext(ctx) { + if (!ctx) return; + if (ctx.theme) document.documentElement.dataset.theme = ctx.theme; + const vars = ctx.styles && ctx.styles.variables; + if (vars) { + for (const [key, value] of Object.entries(vars)) { + document.documentElement.style.setProperty(key, value); + } + } + } + + /** + * Perform the ui/initialize handshake. + * + * Params are flat: appInfo / appCapabilities / protocolVersion. Nesting the + * capabilities or sending clientInfo instead of appInfo fails the host's + * schema check, and a rejected handshake is silent -- the host simply never + * answers, so the app never sends `initialized` and the host never delivers + * the tool result. See @modelcontextprotocol/ext-apps App.connect(). + * + * `onFail` is called if the host never completes the handshake, so the panel + * can say so instead of sitting on a spinner forever. + */ + function connect({ name, version = "1.0.0", onResult, onContext, onFail }) { + onToolResult = onResult; + onHostContext = (ctx) => { applyHostContext(ctx); if (onContext) onContext(ctx); }; + + setTimeout(() => { + if (!ready && onFail) { + onFail(new Error("The host did not complete the ui/initialize handshake.")); + } + }, 5000); + + return request("ui/initialize", { + appInfo: { name, version }, + appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] }, + protocolVersion: PROTOCOL_VERSION, + }).then((result) => { + ready = true; + hostCapabilities = (result && result.hostCapabilities) || {}; + applyHostContext(result && result.hostContext); + notify("ui/notifications/initialized", {}); + reportSize(); + return result; + }).catch((err) => { + if (onFail) onFail(err); + throw err; + }); + } + + return { + connect, + callTool, + updateModelContext, + sendMessage, + requestDisplayMode, + reportSize, + hostSupports: (name) => Boolean(hostCapabilities[name]), + }; +})(); diff --git a/src/beaconmcp/proxmox/apps/cluster_panel.html b/src/beaconmcp/proxmox/apps/cluster_panel.html new file mode 100644 index 0000000..c89f853 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/cluster_panel.html @@ -0,0 +1,292 @@ + + + +Cluster dashboard + + + +
Loading…
+ +
+
+
+

Cluster

+
+
+ +
+ +
+ +
Guests
+
+
+ +
+
+ +
+
+
+ + + + +
+ + diff --git a/src/beaconmcp/proxmox/apps/logs_panel.html b/src/beaconmcp/proxmox/apps/logs_panel.html new file mode 100644 index 0000000..907b565 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/logs_panel.html @@ -0,0 +1,194 @@ + + + +Log viewer + + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ + diff --git a/src/beaconmcp/proxmox/apps/panel.css b/src/beaconmcp/proxmox/apps/panel.css new file mode 100644 index 0000000..c3fd5d9 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/panel.css @@ -0,0 +1,93 @@ +/* Shared look for the ui:// panels. Injected alongside bridge.js by panel.py. + + Colours are defaults only: the host overrides them through + hostContext.styles.variables, so the panels sit inside the client's own + theme rather than next to it. */ + +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --line: #e5e7eb; + --accent: #2563eb; + --danger: #dc2626; + --ok: #16a34a; + --warn: #d97706; + --track: #f3f4f6; +} +:root[data-theme="dark"] { + --bg: #1c1c1c; + --fg: #ededed; + --muted: #9ca3af; + --line: #333333; + --accent: #60a5fa; + --danger: #f87171; + --ok: #4ade80; + --warn: #fbbf24; + --track: #2a2a2a; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 14px; + background: var(--bg); + color: var(--fg); + font: 13px/1.45 var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif); +} + +h1 { font-size: 15px; margin: 0; font-weight: 600; } +.sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } + +.badge { + padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; + border: 1px solid var(--line); color: var(--muted); +} +.badge[data-status="running"], .badge[data-status="online"], .badge[data-status="OK"] { color: var(--ok); border-color: currentColor; } +.badge[data-status="paused"] { color: var(--accent); border-color: currentColor; } +.badge[data-status="unreachable"], .badge[data-status="unknown"] { color: var(--danger); border-color: currentColor; } + +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 14px 0; } +.metric { border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; } +.metric .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; } +.metric .value { font-size: 15px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; } + +.bar { height: 4px; border-radius: 2px; background: var(--track); margin-top: 7px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); } +.bar > i[data-level="warn"] { background: var(--warn); } +.bar > i[data-level="high"] { background: var(--danger); } + +fieldset { border: 0; margin: 0; padding: 0; } +fieldset[disabled] { opacity: .55; } +.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: flex-end; } + +button { + font: inherit; padding: 6px 13px; border-radius: 6px; cursor: pointer; + border: 1px solid var(--line); background: transparent; color: var(--fg); +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { cursor: not-allowed; opacity: .45; } +button.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); } + +label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; } +input, select { + font: inherit; padding: 5px 7px; border-radius: 6px; + border: 1px solid var(--line); background: var(--bg); color: var(--fg); +} + +hr { border: 0; border-top: 1px solid var(--line); margin: 14px 0; } +.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 8px; } + +.note { margin-top: 12px; padding: 8px 10px; border-radius: 6px; font-size: 12px; border: 1px solid var(--line); } +.note[data-kind="error"], #loading[data-kind="error"] { color: var(--danger); border-color: currentColor; } +.note[data-kind="ok"] { color: var(--ok); border-color: currentColor; } + +table { border-collapse: collapse; width: 100%; font-size: 12px; } +th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; padding: 5px 8px; } +td { padding: 5px 8px; border-top: 1px solid var(--line); } +tbody tr:hover { background: var(--track); } + +[hidden] { display: none !important; } diff --git a/src/beaconmcp/proxmox/apps/vm_panel.html b/src/beaconmcp/proxmox/apps/vm_panel.html new file mode 100644 index 0000000..29fe698 --- /dev/null +++ b/src/beaconmcp/proxmox/apps/vm_panel.html @@ -0,0 +1,215 @@ + + + +VM control panel + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+
CPU
+
+
+
+
+
Memory
+
+
+
+
+
Disk
+
+
+
+
+
Uptime
+
+
+
+ +
+
Power
+
+ + + + + +
+ +
+ +
Resources
+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py index fee7fb7..0dd1670 100644 --- a/src/beaconmcp/proxmox/panel.py +++ b/src/beaconmcp/proxmox/panel.py @@ -1,4 +1,4 @@ -"""MCP Apps panel for a single VM/CT. +"""MCP Apps panels. The MCP Apps extension (`io.modelcontextprotocol/ui`) lets a tool carry a reference to an interactive UI: `_meta.ui.resourceUri` points at a `ui://` @@ -11,8 +11,13 @@ are already on `FastMCP`. Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's -return value, which is why `proxmox_vm_panel` returns the full snapshot as -data rather than a "see the panel" placeholder. +return value, which is why every panel tool returns its full snapshot as data +rather than a "see the panel" placeholder. + +Each panel is one HTML file under ``apps/``. They share ``bridge.js`` (the +JSON-RPC client) and ``panel.css`` (the look), spliced in at the +```` marker so what ships to the host stays a single +self-contained document. """ from __future__ import annotations @@ -22,19 +27,38 @@ from mcp.server.fastmcp import FastMCP +from .aggregators import ( + _collect_node_summaries, + _collect_storage_summaries, + _collect_vm_summaries, +) from .client import ProxmoxClient -PANEL_URI = "ui://beaconmcp/vm-panel.html" APP_MIME_TYPE = "text/html;profile=mcp-app" -_PANEL_HTML = Path(__file__).parent / "vm_panel.html" +VM_PANEL_URI = "ui://beaconmcp/vm-panel.html" +LOGS_PANEL_URI = "ui://beaconmcp/logs-panel.html" +CLUSTER_PANEL_URI = "ui://beaconmcp/cluster-panel.html" + +_APPS_DIR = Path(__file__).parent / "apps" +_RUNTIME_MARKER = "" _MB = 1048576 _GB = 1073741824 -def _snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: - """Everything the panel renders, in one pass over both guest types.""" +def _read_app(name: str) -> str: + """Load a panel document with the shared CSS and bridge spliced in.""" + html = (_APPS_DIR / name).read_text(encoding="utf-8") + runtime = ( + f"" + f"" + ) + return html.replace(_RUNTIME_MARKER, runtime) + + +def _vm_snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: + """Everything the VM panel renders, in one pass over both guest types.""" for vm_type in ("qemu", "lxc"): data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") if isinstance(data, dict) and "error" in data: @@ -73,7 +97,7 @@ def _snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: @mcp.tool( - meta={"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}}, + meta={"ui": {"resourceUri": VM_PANEL_URI, "visibility": ["model", "app"]}}, ) def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: """Open an interactive control panel for one VM or container. @@ -92,14 +116,107 @@ def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: mem_max_mb, disk_used_gb, disk_max_gb, uptime_h, cores, memory_mb}. ``disk_used_gb`` is null when the guest does not report it. """ - return _snapshot(client, node, vmid) - - @mcp.resource( - PANEL_URI, - name="vm-panel", - title="VM control panel", - description="Interactive panel for a single Proxmox VM or container.", - mime_type=APP_MIME_TYPE, + return _vm_snapshot(client, node, vmid) + + @mcp.tool( + meta={"ui": {"resourceUri": LOGS_PANEL_URI, "visibility": ["model", "app"]}}, ) - def vm_panel_app() -> str: - return _PANEL_HTML.read_text(encoding="utf-8") + def proxmox_logs_panel(node: str, source: str = "syslog", limit: int = 200) -> dict[str, Any]: + """Open a scrollable, filterable log and task viewer for one node. + + Prefer this over proxmox_get_logs whenever the user wants to *read* + logs rather than have them summarised: the panel keeps every line, + highlights errors and warnings, filters as you type, and can switch + between the syslog and the Proxmox task list without another turn. + + Args: + source: 'syslog' for system logs, 'tasks' for the task history. + limit: Lines to fetch, capped at 500 by the Proxmox API. + + Returns: {node, source, entries: [...]}. For syslog each entry is + {text, level} where level is error/warn/info, guessed from the line. + For tasks each entry is {upid, type, status, user, starttime, endtime, + level}. + """ + limit = min(limit, 500) + + if source == "tasks": + data = client.get(node, f"nodes/{node}/tasks", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + { + "upid": t.get("upid"), + "type": t.get("type"), + "status": t.get("status"), + "user": t.get("user"), + "starttime": t.get("starttime"), + "endtime": t.get("endtime"), + # Proxmox writes "OK" for success and a message otherwise; + # a still-running task has no status yet. + "level": "info" if t.get("status") in ("OK", None) else "error", + } + for t in (data if isinstance(data, list) else []) + ] + return {"node": node, "source": "tasks", "entries": entries} + + data = client.get(node, f"nodes/{node}/syslog", limit=limit) + if isinstance(data, dict) and "error" in data: + return data + entries = [ + {"text": line, "level": _syslog_level(line)} + for line in (entry.get("t", "") for entry in (data if isinstance(data, list) else [])) + ] + return {"node": node, "source": "syslog", "entries": entries} + + @mcp.tool( + meta={"ui": {"resourceUri": CLUSTER_PANEL_URI, "visibility": ["model", "app"]}}, + ) + def cluster_overview_interactive(include_storage: bool = True) -> dict[str, Any]: + """Open an interactive cluster dashboard: nodes, guests and storage. + + The same data as cluster_overview, but rendered as a browsable panel: + nodes with CPU and memory pressure, a searchable guest table with + per-row start/stop, and storage pools with usage bars. Use it when the + user wants to look around the cluster rather than ask one question + about it. + + Returns: {nodes: [...], vms: [...], total_vms, storage: [...]}. + """ + nodes = _collect_node_summaries(client) + vms, total_vms = _collect_vm_summaries(client) + out: dict[str, Any] = {"nodes": nodes, "vms": vms, "total_vms": total_vms} + if include_storage: + out["storage"] = _collect_storage_summaries(client) + return out + + _register_app_resource(mcp, VM_PANEL_URI, "vm-panel", "VM control panel", "vm_panel.html") + _register_app_resource(mcp, LOGS_PANEL_URI, "logs-panel", "Log viewer", "logs_panel.html") + _register_app_resource( + mcp, CLUSTER_PANEL_URI, "cluster-panel", "Cluster dashboard", "cluster_panel.html", + ) + + +# Cheap keyword scan. Proxmox hands us raw journald text with no severity +# field, so the alternative to guessing is showing every line flat -- which is +# the thing this panel exists to fix. Over-flagging a line is harmless; the +# filter box is there for when it gets noisy. +_ERROR_WORDS = ("error", "fail", "fatal", "critical", "panic", "segfault", "refused", "timeout") +_WARN_WORDS = ("warn", "deprecat", "retry", "degraded", "unable") + + +def _syslog_level(line: str) -> str: + lowered = line.lower() + if any(word in lowered for word in _ERROR_WORDS): + return "error" + if any(word in lowered for word in _WARN_WORDS): + return "warn" + return "info" + + +def _register_app_resource( + mcp: FastMCP, uri: str, name: str, title: str, filename: str, +) -> None: + @mcp.resource(uri, name=name, title=title, mime_type=APP_MIME_TYPE) + def _app() -> str: + return _read_app(filename) diff --git a/src/beaconmcp/proxmox/vm_panel.html b/src/beaconmcp/proxmox/vm_panel.html deleted file mode 100644 index 3bef7a9..0000000 --- a/src/beaconmcp/proxmox/vm_panel.html +++ /dev/null @@ -1,355 +0,0 @@ - - -VM control panel - - -
Loading…
- -
-
-
-

-
-
- -
- -
-
-
CPU
-
-
-
-
-
Memory
-
-
-
-
-
Disk
-
-
-
-
-
Uptime
-
-
-
- -
-
Power
-
- - - - -
- -
- -
Resources
-
-
- - -
-
- - -
- -
-
- - -
- - diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py index 5975db0..ee9b699 100644 --- a/tests/test_mcp_apps_panel.py +++ b/tests/test_mcp_apps_panel.py @@ -1,6 +1,6 @@ -"""Tests for the MCP Apps VM panel. +"""Tests for the MCP Apps panels. -The value here is the wire format: a host only renders the panel if the tool +The value here is the wire format: a host only renders a panel if the tool carries `_meta.ui.resourceUri` and the resource comes back under the mcp-app MIME type, so these assert what goes out on the wire rather than the Python objects behind it. @@ -12,11 +12,20 @@ from pathlib import Path from typing import Any +import pytest from mcp.server.fastmcp import FastMCP sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from beaconmcp.proxmox.panel import APP_MIME_TYPE, PANEL_URI, _snapshot, register_panel_tools +from beaconmcp.proxmox.panel import ( + APP_MIME_TYPE, + CLUSTER_PANEL_URI, + LOGS_PANEL_URI, + VM_PANEL_URI, + _syslog_level, + _vm_snapshot, + register_panel_tools, +) _NOT_FOUND = {"error": "Configuration file 'nodes/pve1/qemu/100.conf' does not exist"} @@ -33,16 +42,25 @@ "uptime": 7200, } +_PANELS = [ + ("proxmox_vm_panel", VM_PANEL_URI), + ("proxmox_logs_panel", LOGS_PANEL_URI), + ("cluster_overview_interactive", CLUSTER_PANEL_URI), +] + class _Client: """Minimal ProxmoxClient stand-in keyed on the path suffix.""" - def __init__(self, responses: dict[str, Any]) -> None: - self.responses = responses + def __init__(self, responses: dict[str, Any] | None = None) -> None: + self.responses = responses or {} self.calls: list[str] = [] + self.kwargs: list[dict[str, Any]] = [] + self.configured_nodes = ["pve1"] - def get(self, node: str, path: str) -> Any: + def get(self, node: str, path: str, **kwargs: Any) -> Any: self.calls.append(path) + self.kwargs.append(kwargs) for suffix, value in self.responses.items(): if path.endswith(suffix): return value @@ -55,31 +73,42 @@ def _server(client: Any) -> FastMCP: return mcp -# --- wire format ------------------------------------------------------------ +async def _tool(mcp: FastMCP, name: str) -> Any: + return next(t for t in await mcp.list_tools() if t.name == name) -async def test_tool_points_at_the_panel_resource() -> None: - tools = await _server(_Client({})).list_tools() - panel = next(t for t in tools if t.name == "proxmox_vm_panel") - assert panel.meta == {"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}} +# --- wire format ------------------------------------------------------------ -async def test_resource_is_served_as_an_mcp_app() -> None: - resources = await _server(_Client({})).list_resources() - panel = next(r for r in resources if str(r.uri) == PANEL_URI) - assert panel.mimeType == APP_MIME_TYPE +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_tool_points_at_its_panel(name: str, uri: str) -> None: + tool = await _tool(_server(_Client()), name) + assert tool.meta == {"ui": {"resourceUri": uri, "visibility": ["model", "app"]}} -async def test_advertised_resource_uri_actually_resolves() -> None: - """A tool whose resourceUri 404s on resources/read renders as a blank frame.""" - mcp = _server(_Client({})) - tools = await mcp.list_tools() - uri = next(t for t in tools if t.name == "proxmox_vm_panel").meta["ui"]["resourceUri"] +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_advertised_resource_resolves_as_an_app(name: str, uri: str) -> None: + """A resourceUri that 404s on resources/read renders as a blank frame.""" + mcp = _server(_Client()) + listed = next(r for r in await mcp.list_resources() if str(r.uri) == uri) + assert listed.mimeType == APP_MIME_TYPE contents = list(await mcp.read_resource(uri)) - assert contents, f"{uri} is advertised but reads back empty" - assert "ui/initialize" in contents[0].content, "panel HTML is missing the host handshake" + + +@pytest.mark.parametrize("name,uri", _PANELS) +async def test_shared_runtime_is_spliced_in(name: str, uri: str) -> None: + """The marker must be gone and both halves of the runtime present. + + A panel that ships with the literal still in it has no + bridge and no styles: it loads, does nothing, and never speaks to the host. + """ + html = list(await _server(_Client()).read_resource(uri))[0].content + + assert "" not in html + assert "const MCPApp" in html, "bridge.js missing" + assert "--font-sans" in html, "panel.css missing" async def test_handshake_params_are_flat() -> None: @@ -91,8 +120,7 @@ async def test_handshake_params_are_flat() -> None: and never receives the tool result. The panel then sits on "Loading..." with nothing in the console to explain it. """ - mcp = _server(_Client({})) - html = list(await mcp.read_resource(PANEL_URI))[0].content + html = list(await _server(_Client()).read_resource(VM_PANEL_URI))[0].content params = html.split('request("ui/initialize", {', 1)[1].split("})", 1)[0] assert "appInfo:" in params @@ -102,14 +130,14 @@ async def test_handshake_params_are_flat() -> None: assert "capabilities: {" not in params -# --- snapshot mapping ------------------------------------------------------- +# --- VM snapshot ------------------------------------------------------------ def test_qemu_disk_usage_is_null_not_zero() -> None: """0 from QEMU means "no guest agent", which must not render as an empty bar.""" client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) - snap = _snapshot(client, "pve1", 100) + snap = _vm_snapshot(client, "pve1", 100) assert snap["disk_used_gb"] is None assert snap["disk_max_gb"] == 32.0 @@ -118,23 +146,12 @@ def test_qemu_disk_usage_is_null_not_zero() -> None: def test_qemu_snapshot_maps_status_and_config() -> None: client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) - snap = _snapshot(client, "pve1", 100) - - assert snap == { - "node": "pve1", - "vmid": 100, - "type": "qemu", - "name": "web-101", - "status": "running", - "cpu_pct": 12.3, - "cpus": 4, - "mem_used_mb": 2048, - "mem_max_mb": 4096, - "disk_used_gb": None, - "disk_max_gb": 32.0, - "uptime_h": 2.0, - "cores": 4, - "memory_mb": 4096, + assert _vm_snapshot(client, "pve1", 100) == { + "node": "pve1", "vmid": 100, "type": "qemu", "name": "web-101", + "status": "running", "cpu_pct": 12.3, "cpus": 4, + "mem_used_mb": 2048, "mem_max_mb": 4096, + "disk_used_gb": None, "disk_max_gb": 32.0, "uptime_h": 2.0, + "cores": 4, "memory_mb": 4096, } @@ -148,16 +165,14 @@ def test_falls_through_to_lxc_when_no_qemu_guest() -> None: "lxc/200/config": {"cores": 1, "memory": 512}, }) - snap = _snapshot(client, "pve1", 200) + snap = _vm_snapshot(client, "pve1", 200) assert snap["type"] == "lxc" assert snap["disk_used_gb"] == 5.0 def test_missing_guest_reports_an_error() -> None: - snap = _snapshot(_Client({}), "pve1", 999) - - assert "not found" in snap["error"] + assert "not found" in _vm_snapshot(_Client(), "pve1", 999)["error"] def test_unreadable_config_still_renders_live_state() -> None: @@ -167,7 +182,7 @@ def test_unreadable_config_still_renders_live_state() -> None: "qemu/100/config": {"error": "connection refused"}, }) - snap = _snapshot(client, "pve1", 100) + snap = _vm_snapshot(client, "pve1", 100) assert snap["status"] == "running" assert snap["cores"] is None @@ -177,7 +192,56 @@ def test_transport_error_is_not_mistaken_for_a_missing_guest() -> None: """Only a "does not exist" error means "try the other guest type".""" client = _Client({"qemu/100/status/current": {"error": "connection refused"}}) - snap = _snapshot(client, "pve1", 100) + snap = _vm_snapshot(client, "pve1", 100) assert snap["error"] == "connection refused" assert not any("lxc" in call for call in client.calls) + + +# --- logs panel ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "line,expected", + [ + ("kernel: EXT4-fs error (device sda1)", "error"), + ("corosync: connection refused to node pve2", "error"), + ("pvedaemon: deprecated option 'foo'", "warn"), + ("systemd[1]: Started Daily apt upgrade.", "info"), + ], +) +def test_syslog_lines_are_classified(line: str, expected: str) -> None: + assert _syslog_level(line) == expected + + +async def test_logs_panel_labels_syslog_lines() -> None: + client = _Client({"syslog": [{"t": "kernel: I/O error"}, {"t": "systemd: Started thing"}]}) + tool = await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1"}) + + entries = tool[1]["entries"] + assert [e["level"] for e in entries] == ["error", "info"] + assert entries[0]["text"] == "kernel: I/O error" + + +async def test_logs_panel_flags_failed_tasks() -> None: + """Proxmox writes "OK" on success; anything else is the failure message.""" + client = _Client({"tasks": [ + {"upid": "UPID:1", "type": "vzdump", "status": "OK", "user": "root@pam"}, + {"upid": "UPID:2", "type": "qmstart", "status": "unable to start VM", "user": "root@pam"}, + {"upid": "UPID:3", "type": "qmigrate", "status": None, "user": "root@pam"}, + ]}) + + result = await _server(client).call_tool( + "proxmox_logs_panel", {"node": "pve1", "source": "tasks"}, + ) + + # A running task has no status yet and must not be painted as a failure. + assert [e["level"] for e in result[1]["entries"]] == ["info", "error", "info"] + + +async def test_logs_panel_caps_the_line_count() -> None: + """The Proxmox API rejects anything above 500, and the panel's box has no max.""" + client = _Client({"syslog": []}) + await _server(client).call_tool("proxmox_logs_panel", {"node": "pve1", "limit": 9000}) + + assert client.kwargs[0]["limit"] == 500 From 9f496cb8fb7954c0e7bffb70f0a4638ba847797c Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:20:35 +0200 Subject: [PATCH 4/8] feat(auth): passkey sign-in + confirmation step on both login pages (#36) * feat(auth): passkey sign-in and a confirmation step on both login pages Adds WebAuthn as an alternative second factor on /app/login and /oauth/authorize. A passkey replaces the TOTP code, never the client secret: both pages keep the client_id + client_secret step first, so a stolen passkey is useless on its own -- and the dashboard session has to encrypt the secret anyway to re-mint MCP bearers later. Both pages now confirm before moving on, instead of redirecting the instant 2FA clears: * the validate button shimmers while the request is in flight, and Enter submits as soon as the sixth digit lands; * the screen that follows shows when access expires, offers to enrol a passkey on this device, and waits for "Finish signing in". On /oauth/authorize that also fixes a latent papercut: the authorization code is minted at "Finish", not before, so its 60 s OAuth 2.1 lifetime is no longer burned while a human reads the page. The approval is held as a single-use in-memory ticket instead. Both flows stay fully functional with JavaScript off -- the forms POST and the confirmation panel renders server-side. Storage is a new `passkeys` table (schema v5) holding a credential id, a public key and a signature counter; challenges are in-memory and single-use. Credentials are listed and removable from /app/tokens. Dynamically-registered clients delegate to their owner's passkeys, the same way they already delegate TOTP. py_webauthn is imported lazily: without it the pages simply hide every passkey affordance and TOTP remains the only way in. Same when the browser has no secure context (plain-HTTP LAN deployments). Tests drive the real ceremonies against a software ES256 authenticator built in the test module, covering wrong origin, wrong RP ID, replayed challenge, foreign credential, counter regression and the CSRF rotation that signing in performs. * fix(passkeys): say why passkeys are unavailable instead of hiding silently When the second factor cleared, the confirmation screen simply had no "Add a passkey" button and no explanation. Two independent gates can switch the feature off and neither was surfaced anywhere: * server side, the optional `webauthn` package may not be installed (a fresh `git pull` without `pip install -e .` is enough); * browser side, WebAuthn is only exposed on a secure context, so a plain-HTTP origin has no API to call. Now: * the boot banner prints `Passkeys: enabled` or `disabled - `; * `beaconmcp doctor` gained a Passkeys section naming the fix command; * the pages render a short hint when the *server* offers passkeys but the *browser* refuses them, instead of dropping the button with no trace. Hiding the affordance from an anonymous visitor is still right -- there is nothing they could do about it -- but the operator now has three places to ask the question and get an answer. --- docs/dashboard.md | 31 +- docs/security.md | 45 + pyproject.toml | 3 + src/beaconmcp/__main__.py | 1400 +++++++++++++---- src/beaconmcp/dashboard/app.py | 388 ++++- src/beaconmcp/dashboard/db.py | 26 +- src/beaconmcp/dashboard/passkeys.py | 598 +++++++ src/beaconmcp/dashboard/static/app.css | 147 ++ src/beaconmcp/dashboard/static/login.js | 331 +++- src/beaconmcp/dashboard/static/webauthn.js | 119 ++ src/beaconmcp/dashboard/templates/login.html | 72 +- src/beaconmcp/dashboard/templates/tokens.html | 33 + tests/test_dashboard_unit.py | 4 +- tests/test_passkeys.py | 808 ++++++++++ 14 files changed, 3707 insertions(+), 298 deletions(-) create mode 100644 src/beaconmcp/dashboard/passkeys.py create mode 100644 src/beaconmcp/dashboard/static/webauthn.js create mode 100644 tests/test_passkeys.py diff --git a/docs/dashboard.md b/docs/dashboard.md index 162513b..6f4d567 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -2,7 +2,7 @@ Optional web panel served by BeaconMCP on the same origin as the MCP endpoint (`https:///app/...`). Three pages: -- **`/app/login`** — exchanges a client id + client secret + TOTP code for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. +- **`/app/login`** — exchanges a client id + client secret + a second factor (TOTP code or passkey) for an MCP bearer, and stores it in a 90-day HttpOnly session cookie. Removes the need to issue `curl` requests from a phone. - **`/app/chat`** — multi-conversation chat with Gemini 2.5 Flash/Pro (stable) or Gemini 3 Flash / 3.1 Pro (preview, Google allowlist required). **Requires `GEMINI_API_KEY`.** - **`/app/tokens`** — generates named bearers so external MCP clients (Gemini web, ChatGPT, Assistant Desktop) can be wired up without the OAuth dance. **Works without `GEMINI_API_KEY`.** @@ -36,9 +36,32 @@ or `(chat: disabled, tokens only)` when the Gemini key is missing, or `disabled` ## Flow 1. Navigate to `https:///` on any device — the root path redirects to `/app/login`. -2. Enter client id + client secret + current TOTP code (once per 90-day window). -3. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. -4. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. +2. Enter client id + client secret, then the current TOTP code — or use a passkey instead, from the + link under the code boxes (see [Passkeys](#passkeys)). Enter validates as soon as the + sixth digit lands. +3. A confirmation screen shows when the MCP access and the signed-in session expire, offers to + register a passkey on this device, and waits for **Finish signing in**. +4. Land on `/app/chat` (or `/app/tokens` in tokens-only mode) with the conversation history restored. +5. Every 24 hours the underlying MCP bearer expires. The dashboard prompts only for a fresh TOTP code on `/app/refresh`; the client id and secret remain encrypted server-side. + +With JavaScript disabled the page falls back to the previous behaviour: a plain form POST that +redirects straight to the landing page, no confirmation screen and no passkeys. + +## Passkeys + +A passkey replaces the 6-digit code, not the client secret — signing in still needs both factors. +Enrol one from the confirmation screen right after a normal sign-in, then on the next visit use +**Use a passkey instead** under the code boxes. + +The same option is on the OAuth consent page (`/oauth/authorize`), which now also confirms before +redirecting: it shows when the granted access expires, offers passkey enrolment, and only mints the +authorization code when you press **Finish signing in**. + +Registered passkeys are listed at the bottom of `/app/tokens` with their label, when they were added +and when they were last used, each with a **Remove** button. + +Caveats — hostname binding, the HTTPS/loopback requirement, and why you should keep your TOTP seed — +are covered in [security.md](security.md#passkeys). ## Tokens page diff --git a/docs/security.md b/docs/security.md index 7730f6b..d26862a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -56,6 +56,51 @@ Unattended services (scheduled jobs, CI pipelines) sometimes genuinely need mach case, with its required precautions, is covered separately in [totp-automation.md](totp-automation.md). Read it end to end before deciding. +## Passkeys + +A passkey (WebAuthn) replaces the **TOTP factor**, never the client secret. Both login pages keep the +same two-factor shape: + +1. `client_id` + `client_secret` +2. a 6-digit code **or** a passkey assertion + +That ordering is what makes a stolen passkey worthless on its own, and it is also a practical +constraint: the dashboard session encrypts the client secret so it can re-mint MCP bearers later, so +a fully usernameless login could not build a working session anyway. + +Where they work: + +| Page | Sign in with a passkey | Enrol a passkey | +|------|------------------------|-----------------| +| `/app/login` | Link under the 2FA step | On the post-2FA screen | +| `/oauth/authorize` | Link under the 2FA step | On the approval screen | + +Credentials are stored in the dashboard database (`passkeys` table): a credential id, a **public** +key and a signature counter. Nothing secret leaves the authenticator. + +Things worth knowing before you enrol: + +- **Passkeys are bound to the hostname.** The relying-party ID is derived from the request host, so + a credential registered on `beacon.example` will not work on `beacon.internal` or on a raw IP that + differs from the one used at registration. Settle on your public hostname first. +- **A secure context is required.** Browsers only expose the WebAuthn API over HTTPS or on loopback. + On a plain-HTTP LAN deployment the passkey buttons are hidden and TOTP stays the only path in. +- **Keep TOTP working.** Passkeys are an alternative, not a replacement: losing every enrolled device + must not lock you out. The authenticator seed remains the recovery path. +- **Dynamically-registered clients delegate**, exactly like TOTP: a client created through the DCR + bootstrap is authorized by its *owner's* passkeys, so the second factor never leaves the owner. +- Registrations, revocations and passkey sign-ins are recorded in the audit log + (`dashboard.passkey.*`, `auth.passkey.*`, and `login`/`authorize` events tagged `via=passkey`). + +Passkey ceremonies are rate-limited per IP by the same limiter that guards `/app/login`, and a TOTP +lockout also blocks the passkey path for that client. + +Manage enrolled credentials from `/app/tokens`, or drop them all for a client with: + +```sql +DELETE FROM passkeys WHERE client_id = 'beaconmcp_...'; +``` + ## Audit trail `server.audit_log` records tool calls, dashboard logins, OAuth authorizations and client revocations diff --git a/pyproject.toml b/pyproject.toml index a18ee64..2421871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ dependencies = [ "python-dotenv", "pyyaml", "pyotp", + # Passkeys (WebAuthn) on the login / authorize pages. Imported lazily: + # the auth pages fall back to TOTP-only when it is missing. + "webauthn>=2,<4", "qrcode", "cryptography>=42", "jinja2>=3", diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 5232f7a..443f366 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -320,6 +320,18 @@ async def check_bmc() -> None: else: print(" - No BMC devices configured.") + # Only the library half can be checked here: credential storage depends + # on the running server's database, which doctor does not open. + print("\nPasskeys (WebAuthn):") + from .dashboard.passkeys import webauthn_installed + + if webauthn_installed(): + print(" ✓ 'webauthn' package importable.") + print(" Browsers additionally require HTTPS or a loopback host.") + else: + print(" ✗ 'webauthn' package is missing - login pages offer TOTP only.") + print(" Fix with: pip install 'webauthn>=2,<4' (or reinstall BeaconMCP)") + print("\nPreflight check complete.") @@ -431,9 +443,630 @@ def _cmd_auth(args): sys.exit(1) +# --------------------------------------------------------------------------- +# /oauth/authorize page assets +# +# Kept as plain module-level strings rather than inlined in the f-string that +# builds the page: CSS and JS are brace-dense, and doubling every one of them +# to survive `str.format` made the block unreadable and easy to break. +# --------------------------------------------------------------------------- + +_AUTHORIZE_CSS = """ +:root { + --accent: oklch(0.68 0.17 48); + --accent-soft: oklch(0.68 0.17 48 / 0.12); + --accent-softer: oklch(0.68 0.17 48 / 0.06); + --accent-border: oklch(0.68 0.17 48 / 0.35); + --accent-hover: oklch(0.62 0.18 48); + --accent-fg: #fff; + --bg: oklch(0.99 0.004 70); + --bg-soft: oklch(0.975 0.005 70); + --bg-elev: #fff; + --fg: oklch(0.22 0.01 70); + --fg-mid: oklch(0.42 0.008 70); + --fg-muted: oklch(0.55 0.008 70); + --fg-faint: oklch(0.7 0.006 70); + --border: oklch(0.92 0.006 70); + --border-strong: oklch(0.86 0.008 70); + --border-subtle: oklch(0.95 0.005 70); + --danger: oklch(0.58 0.19 25); + --danger-soft: oklch(0.58 0.19 25 / 0.1); + --success: oklch(0.62 0.12 155); + --success-soft: oklch(0.62 0.12 155 / 0.12); + --shadow: 0 1px 2px rgba(20,14,8,0.04), 0 4px 20px rgba(20,14,8,0.06); + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} +[data-theme="dark"] { + --bg: oklch(0.16 0.008 60); + --bg-soft: oklch(0.19 0.008 60); + --bg-elev: oklch(0.21 0.009 60); + --fg: oklch(0.95 0.006 70); + --fg-mid: oklch(0.78 0.008 70); + --fg-muted: oklch(0.62 0.01 70); + --fg-faint: oklch(0.45 0.008 70); + --border: oklch(0.28 0.009 60); + --border-strong: oklch(0.36 0.01 60); + --border-subtle: oklch(0.24 0.008 60); + --accent: oklch(0.75 0.17 50); + --accent-soft: oklch(0.75 0.17 50 / 0.16); + --accent-softer: oklch(0.75 0.17 50 / 0.08); + --accent-border: oklch(0.75 0.17 50 / 0.4); + --accent-hover: oklch(0.82 0.17 50); + --accent-fg: oklch(0.12 0.008 60); + --danger: oklch(0.68 0.19 25); + --success: oklch(0.72 0.14 155); + --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 20px rgba(0,0,0,0.4); +} +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + font-family: var(--font); + font-size: 15px; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} +body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} +.auth-card { + width: 100%; max-width: 380px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow); + animation: rise 400ms var(--ease-out) both; +} +@keyframes rise { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.auth-brand { display: flex; align-items: center; margin-bottom: 26px; } +.auth-brand .name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } +h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; } +.sub { margin: 0 0 18px; font-size: 13.5px; color: var(--fg-muted); } +.sub strong { color: var(--fg); font-weight: 600; } +.banner { + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + margin: 0 0 14px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--border)); +} +.banner-success { + background: var(--success-soft); + color: var(--success); + border-color: color-mix(in oklab, var(--success) 35%, var(--border)); +} +.toast-banner { + background: var(--accent-softer); + border: 1px solid var(--accent-border); + color: var(--fg); + border-radius: 10px; + padding: 9px 12px; + font-size: 12.5px; + margin-bottom: 16px; + display: flex; align-items: center; gap: 8px; +} +.toast-banner .dot { + width: 6px; height: 6px; + border-radius: 50%; background: var(--accent); + flex-shrink: 0; +} +.toast-banner b { font-family: var(--font-mono); margin-left: 2px; } +.totp-inputs { + display: flex; gap: 8px; justify-content: space-between; + margin: 8px 0 18px; +} +.totp-inputs input { + width: 100%; aspect-ratio: 1 / 1.15; + text-align: center; + font-size: 24px; font-weight: 600; + font-family: var(--font-mono); + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 10px; + color: var(--fg); + outline: none; + transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); +} +.totp-inputs input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +} +.totp-inputs input.filled { + background: var(--accent-softer); + border-color: var(--accent-border); +} +.btn-primary { + width: 100%; + padding: 12px 16px; + border: 0; border-radius: 10px; + background: var(--accent); color: var(--accent-fg); + font-family: var(--font); font-weight: 600; font-size: 14.5px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: background 180ms var(--ease-out), transform 100ms var(--ease-out); + box-shadow: 0 4px 14px oklch(0.68 0.17 48 / 0.28), inset 0 1px 0 rgba(255,255,255,0.2); +} +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-primary:active:not(:disabled) { transform: translateY(1px); } +.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } +.btn-primary.is-loading, .btn-ghost.is-loading { + position: relative; overflow: hidden; cursor: progress; opacity: 1; +} +.btn-primary.is-loading::after, .btn-ghost.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient(100deg, transparent 20%, var(--accent-soft) 50%, transparent 80%); +} +@keyframes shimmer-sweep { to { transform: translateX(100%); } } +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, .btn-ghost.is-loading::after { + animation: none; opacity: 0.25; + } +} +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, .alt-divider::after { + content: ""; flex: 1; height: 1px; background: var(--border); +} +.hint { + margin: 10px 0 0; + font-size: 12.5px; line-height: 1.5; + color: var(--fg-muted); text-align: center; +} +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { font-size: 12.5px; color: var(--fg-muted); } +.expiry-row dd { + margin: 0; font-size: 13px; font-weight: 550; + color: var(--fg); text-align: right; +} +#passkey-add-block { margin-top: 18px; } +#finalize-form { margin-top: 20px; } +""" + + +_AUTHORIZE_THEME_JS = """ +(function() { + try { + var raw = localStorage.getItem("beaconmcp-ui-state"); + var s = raw ? JSON.parse(raw) : {}; + var t = s.theme || "auto"; + var dark = t === "dark" || (t === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.setAttribute("data-theme", dark ? "dark" : "light"); + } catch (e) {} +})(); +""" + + +# Self-contained: /oauth/authorize is reachable even when the dashboard (and +# its /app/static bundle) is disabled, so the WebAuthn plumbing is duplicated +# here rather than imported from static/webauthn.js. +_AUTHORIZE_JS = """ +(function() { + "use strict"; + + var card = document.querySelector(".auth-card"); + var stepVerify = document.getElementById("step-verify"); + var stepDone = document.getElementById("step-done"); + var form = document.getElementById("authorize-form"); + var totpHidden = document.getElementById("totp"); + var verifyBtn = document.getElementById("verify-btn"); + var container = document.getElementById("totp-inputs"); + var inputs = container ? container.querySelectorAll("input") : []; + var verifyError = document.getElementById("verify-error"); + var doneError = document.getElementById("done-error"); + var doneOk = document.getElementById("done-ok"); + var passkeyBlock = document.getElementById("passkey-block"); + var passkeyBtn = document.getElementById("passkey-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var ticketInput = document.getElementById("ticket"); + var finalizeForm = document.getElementById("finalize-form"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeys === "true"; + var clientId = card ? card.dataset.clientId : ""; + var busy = false; + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + function bufToB64url(buf) { + var bytes = new Uint8Array(buf), bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, ""); + } + function descriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + function webauthnSupported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") return "Passkey prompt cancelled or timed out."; + if (err.name === "InvalidStateError") return "This device already has a passkey for this client."; + if (err.name === "SecurityError") return "Passkeys need a secure origin (HTTPS or localhost)."; + return err.message || String(err); + } + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-BeaconMCP-Mode": "json" }, + body: JSON.stringify(body || {}) + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }); + } + function show(el, message) { + if (!el) return; + el.textContent = message || ""; + el.hidden = !message; + } + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "\\u2014"; + var d = new Date(epochSeconds * 1000); + var sameDay = d.toDateString() === new Date().toDateString(); + var time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) + " at " + time; + } + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- TOTP boxes --- + function collectTotp() { + var s = ""; + inputs.forEach(function(i) { s += (i.value || "").replace(/\\D/g, ""); }); + return s; + } + function refresh() { + var v = collectTotp(); + if (totpHidden) totpHidden.value = v; + if (verifyBtn) verifyBtn.disabled = busy || v.length !== 6; + } + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + inputs.forEach(function(inp, i) { + inp.addEventListener("input", function(e) { + var v = (e.target.value || "").replace(/\\D/g, ""); + e.target.value = v.slice(0, 1); + if (v) { + e.target.classList.add("filled"); + if (inputs[i + 1]) inputs[i + 1].focus(); + } else { + e.target.classList.remove("filled"); + } + refresh(); + }); + inp.addEventListener("keydown", function(e) { + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } + if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { + inputs[i - 1].focus(); + inputs[i - 1].value = ""; + inputs[i - 1].classList.remove("filled"); + refresh(); + } + }); + inp.addEventListener("paste", function(e) { + e.preventDefault(); + var src = e.clipboardData || window.clipboardData; + var pasted = ((src && src.getData("text")) || "").replace(/\\D/g, "").slice(0, 6); + pasted.split("").forEach(function(ch, k) { + if (inputs[k]) { inputs[k].value = ch; inputs[k].classList.add("filled"); } + }); + if (inputs[Math.min(pasted.length, 5)]) inputs[Math.min(pasted.length, 5)].focus(); + refresh(); + }); + }); + + // --- approved screen --- + function showDone(payload) { + if (ticketInput) ticketInput.value = payload.ticket || ""; + var accessEl = document.getElementById("access-expiry"); + if (accessEl) { + accessEl.textContent = formatDateTime(payload.access_expires_at) + + " (" + formatRelative(payload.access_expires_at) + ")"; + } + var ticketEl = document.getElementById("ticket-expiry"); + if (ticketEl) ticketEl.textContent = formatRelative(payload.ticket_expires_at); + if (passkeyAddBlock) { + var canAdd = payload.passkeys_enabled && webauthnSupported(); + passkeyAddBlock.hidden = !canAdd; + // Server says passkeys are on but the browser won't expose the API: + // that is an insecure origin, and silently hiding the button just + // makes people wonder where it went. + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + } + if (stepVerify) stepVerify.hidden = true; + if (stepDone) stepDone.hidden = false; + if (finishBtn) finishBtn.focus(); + } + + function submitTotp() { + if (busy || !form) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + show(verifyError, ""); + setLoading(verifyBtn, true, "Verifying\\u2026"); + + var body = new URLSearchParams(new FormData(form)); + body.set("totp", code); + fetch("/oauth/authorize", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-BeaconMCP-Mode": "json" + }, + body: body.toString() + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Authorization failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + show(verifyError, "Network error. Try again."); + refresh(); + }); + } + + if (form) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + submitTotp(); + }); + } + + // --- passkey instead of a code --- + if (passkeyBlock) { + var canUse = passkeysEnabled && webauthnSupported(); + passkeyBlock.hidden = !canUse; + var note = document.getElementById("passkey-unsupported"); + if (note) note.hidden = canUse || !passkeysEnabled; + } + + if (passkeyBtn) { + passkeyBtn.addEventListener("click", function() { + if (busy || !form) return; + busy = true; + show(verifyError, ""); + setLoading(passkeyBtn, true, "Waiting for your passkey\\u2026"); + if (verifyBtn) verifyBtn.disabled = true; + + var params = {}; + new FormData(form).forEach(function(v, k) { params[k] = v; }); + postJson("/oauth/passkey/options", { client_id: clientId }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.allowCredentials = descriptors(res.data.options.allowCredentials); + return navigator.credentials.get({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + return postJson("/oauth/passkey/verify", { + state: res.data.state, + params: params, + credential: { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + authenticatorData: bufToB64url(cred.response.authenticatorData), + signature: bufToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64url(cred.response.userHandle) : null + } + } + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { showDone(res.data); return; } + show(verifyError, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyBtn, false); + refresh(); + show(verifyError, describeError(err)); + }); + }); + } + + // --- enrol a passkey right after approving --- + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + show(doneError, ""); + show(doneOk, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt\\u2026"); + var ticket = ticketInput ? ticketInput.value : ""; + + postJson("/oauth/passkey/register/options", { ticket: ticket }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Could not start registration."); + } + var options = Object.assign({}, res.data.options); + options.challenge = b64urlToBuf(res.data.options.challenge); + options.user = Object.assign({}, res.data.options.user, { + id: b64urlToBuf(res.data.options.user.id) + }); + options.excludeCredentials = descriptors(res.data.options.excludeCredentials); + return navigator.credentials.create({ publicKey: options }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var payload = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(cred.response.clientDataJSON), + attestationObject: bufToB64url(cred.response.attestationObject) + } + }; + if (cred.response.getTransports) { + try { payload.response.transports = cred.response.getTransports(); } catch (e) {} + } + return postJson("/oauth/passkey/register/verify", { + ticket: ticket, state: res.data.state, credential: payload + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + show(doneOk, "Passkey \\u201c" + res.data.passkey.label + + "\\u201d registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + show(doneError, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + show(doneError, describeError(err)); + }); + }); + } + + if (finalizeForm) { + finalizeForm.addEventListener("submit", function() { + setLoading(finishBtn, true, "Redirecting\\u2026"); + }); + } + + if (inputs[0] && !inputs[0].disabled && stepDone && stepDone.hidden) inputs[0].focus(); +})(); +""" + + def _run_http(mcp, host: str, port: int): """Run the MCP server over Streamable HTTP with OAuth client credentials.""" + import datetime as _dt import html + import secrets import uvicorn from contextlib import asynccontextmanager from starlette.applications import Starlette @@ -512,6 +1145,35 @@ async def dispatch(self, request: Request, call_next): ) token_store = TokenStore(db_path=tokens_db, named_token_ttl=named_token_ttl) code_store = CodeStore() + + # Shared dashboard SQLite handle. Three features live in it -- sessions, + # DCR bootstrap slugs and passkeys -- and /oauth/authorize wants the + # passkey table even on deployments that keep the dashboard off, so the + # handle is opened here rather than inside the dashboard builder. + from . import dashboard as _dashboard_mod + from .dashboard import passkeys as passkeys_mod + from .dashboard.db import Database as _Database + + shared_database = None + _database_required = ( + _dashboard_mod.is_enabled() or config.server.allow_dynamic_registration + ) + try: + shared_database = _Database() + except Exception as exc: # noqa: BLE001 + if _database_required: + raise + # Dashboard off and no writable database: passkeys are simply not + # offered. Never a reason to refuse to boot the MCP server. + logging.getLogger("beaconmcp").warning( + "passkey storage unavailable (%s); passkeys disabled", exc, + ) + + passkey_service = passkeys_mod.PasskeyService( + passkeys_mod.PasskeyStore(shared_database) + if shared_database is not None + else None + ) # Share the TokenStore with MCP tools so security_end_session can revoke # the caller's bearer without an import cycle. auth.register_token_store(token_store) @@ -637,10 +1299,84 @@ def _validate_authorize_params( None, ) - def _render_authorize_form( - normalized: dict[str, str], error: str | None = None, locked: bool = False + # Approvals that cleared 2FA but have not been turned into an + # authorization code yet. The operator sits on the confirmation screen + # (session lifetime, optional passkey enrolment) for as long as they + # like; the *code* is only minted when they press "Finish", so its + # 60 s OAuth 2.1 lifetime is never burned by a human reading a page. + # + # In-memory and single-use on purpose: a ticket that survives a restart + # would be a standing authorization nobody asked for. + pending_authorizations: dict[str, tuple[dict[str, str], float]] = {} + PENDING_AUTHORIZATION_TTL = 600.0 + + def _prune_pending() -> None: + now = time.time() + for key in [k for k, (_, exp) in pending_authorizations.items() if now > exp]: + del pending_authorizations[key] + + def _issue_pending(normalized: dict[str, str]) -> tuple[str, float]: + _prune_pending() + ticket = secrets.token_urlsafe(32) + expires_at = time.time() + PENDING_AUTHORIZATION_TTL + pending_authorizations[ticket] = (dict(normalized), expires_at) + return ticket, expires_at + + def _consume_pending(ticket: str) -> dict[str, str] | None: + _prune_pending() + entry = pending_authorizations.pop(ticket, None) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _peek_pending(ticket: str) -> dict[str, str] | None: + """Read a ticket without spending it (passkey enrolment mid-screen).""" + _prune_pending() + entry = pending_authorizations.get(ticket) + if entry is None: + return None + params, expires_at = entry + return params if time.time() <= expires_at else None + + def _passkey_owner(client_id: str) -> str: + """Client whose passkeys guard ``client_id``. + + Mirrors the TOTP delegation in :meth:`ClientStore.check_totp`: a + dynamically-registered client (ChatGPT and friends) has no second + factor of its own, so the human owner's passkeys authorize it. + """ + client = client_store.get(client_id) + if client is not None and client.owner_client_id: + return client.owner_client_id + return client_id + + def _authorize_passkeys_enabled(request: Request, client_id: str) -> bool: + if passkey_service is None or not passkey_service.available: + return False + return passkeys_mod.is_secure_context(request) + + def _wants_json_authorize(request: Request) -> bool: + return request.headers.get("x-beaconmcp-mode", "") == "json" + + def _authorize_page( + normalized: dict[str, str], + *, + request: Request, + error: str | None = None, + locked: bool = False, + ticket: str | None = None, + access_expires_at: float | None = None, + ticket_expires_at: float | None = None, ) -> HTMLResponse: - client_name = client_store.get_name(normalized["client_id"]) or normalized["client_id"] + """Render the two-panel authorize page. + + ``ticket`` switches it to the post-2FA panel. That panel is rendered + server-side too, so the flow still completes with JavaScript off: + the confirmation screen is a plain form posting the ticket back. + """ + client_id = normalized["client_id"] + client_name = client_store.get_name(client_id) or client_id hidden = "\n".join( f'' for k, v in normalized.items() @@ -649,273 +1385,198 @@ def _render_authorize_form( banner = "" if locked: banner = ( - '" + '' ) elif error: - banner = f'' + banner = f'' disabled = "disabled" if locked else "" - # Self-contained page: /oauth/authorize is reachable even when the - # dashboard (and its /app/static bundle) is disabled. Styles/JS live - # inline; same design tokens as the dashboard auth pages. + approved = ticket is not None + passkeys_on = _authorize_passkeys_enabled(request, client_id) + + def _fmt(ts: float | None) -> str: + if not ts: + return "—" + return html.escape( + _dt.datetime.fromtimestamp(ts).strftime("%d %b %Y at %H:%M") + ) + page = f""" -BeaconMCP · Two-factor +BeaconMCP · Authorize - - + + -
+
BeaconMCP
-

Authorize access

-

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

- {banner} -
- - Client: {html.escape(normalized["client_id"])} -
-
+ +
+

Authorize access

+

Enter the 6-digit code from your authenticator to grant access to {html.escape(client_name)}.

+ {banner} +
+ + Client: {html.escape(client_id)} +
+ + {hidden} -
- - - - - - +
+ + + + + + +
+ + + + + +
+ +
+ +

Two-factor confirmed

+

You're about to grant {html.escape(client_name)} access to this BeaconMCP server.

+ + + +
+
+
Access expires
+
{_fmt(access_expires_at)}
+
+
+
This approval expires
+
{_fmt(ticket_expires_at)}
+
+
+

The client will have to go through two-factor again once its access expires.

+ + - - - + + +
+ + +
+
- + """ return HTMLResponse(page) + def _authorize_redirect(normalized: dict[str, str]) -> Response: + """Mint the authorization code and bounce back to the OAuth client.""" + redirect_uri = normalized["redirect_uri"] + code = code_store.issue( + normalized["client_id"], + redirect_uri, + normalized["code_challenge"], + normalized["code_challenge_method"], + ) + query = {"code": code} + if normalized["state"]: + query["state"] = normalized["state"] + parsed = urlparse(redirect_uri) + sep = "&" if parsed.query else "?" + location = f"{redirect_uri}{sep}{urlencode(query)}" + return Response(status_code=302, headers={"Location": location}) + + def _authorize_approved( + request: Request, normalized: dict[str, str], *, as_json: bool = False, + ) -> Response: + """Second factor cleared: hand the operator the confirmation screen. + + ``as_json`` forces the JSON shape for callers that are JSON-only by + construction (the passkey endpoints), independently of whether the + client remembered to send the mode header. + """ + ticket, ticket_expires_at = _issue_pending(normalized) + access_expires_at = time.time() + TokenStore.TOKEN_TTL + if as_json or _wants_json_authorize(request): + return JSONResponse({ + "ok": True, + "ticket": ticket, + "ticket_expires_at": ticket_expires_at, + "access_expires_at": access_expires_at, + "passkeys_enabled": _authorize_passkeys_enabled( + request, normalized["client_id"], + ), + }) + return _authorize_page( + normalized, + request=request, + ticket=ticket, + ticket_expires_at=ticket_expires_at, + access_expires_at=access_expires_at, + ) + + def _authorize_failed( + request: Request, + normalized: dict[str, str], + message: str, + *, + status: int = 401, + locked: bool = False, + ) -> Response: + if _wants_json_authorize(request): + return JSONResponse( + {"ok": False, "error": message, "locked": locked}, + status_code=status, + ) + return _authorize_page( + normalized, request=request, error=message, locked=locked, + ) + async def oauth_authorize_get(request: Request) -> Response: normalized, err = _validate_authorize_params(dict(request.query_params)) if err is not None: return err - return _render_authorize_form( - normalized, locked=totp_locked(normalized["client_id"]) + return _authorize_page( + normalized, + request=request, + locked=totp_locked(normalized["client_id"]), ) async def oauth_authorize_post(request: Request) -> Response: @@ -927,7 +1588,11 @@ async def oauth_authorize_post(request: Request) -> Response: client_id = normalized["client_id"] if totp_locked(client_id): - return _render_authorize_form(normalized, locked=True) + return _authorize_failed( + request, normalized, + "Too many attempts. Try again in 5 minutes.", + status=429, locked=True, + ) code_totp = body.get("totp", "") totp_result = client_store.check_totp(client_id, code_totp) @@ -941,9 +1606,9 @@ async def oauth_authorize_post(request: Request) -> Response: reason=totp_result.value, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) - return _render_authorize_form( - normalized, - error=( + return _authorize_failed( + request, normalized, + ( TOTP_REPLAY_MESSAGE if totp_result is TotpResult.REPLAY else "Incorrect code. Check that your device clock is in sync." @@ -952,24 +1617,195 @@ async def oauth_authorize_post(request: Request) -> Response: ) totp_record_success(client_id) audit.emit( - "auth.authorize.ok", client_id=client_id, + "auth.authorize.2fa", client_id=client_id, ip=client_ip(request, tuple(config.server.trusted_proxies)), ) + return _authorize_approved(request, normalized) - redirect_uri = normalized["redirect_uri"] - code = code_store.issue( - client_id, - redirect_uri, - normalized["code_challenge"], - normalized["code_challenge_method"], + async def oauth_authorize_finalize(request: Request) -> Response: + """Turn an approved ticket into an authorization code and redirect.""" + form = await request.form() + ticket_raw = form.get("ticket", "") + ticket = ticket_raw.strip() if isinstance(ticket_raw, str) else "" + normalized = _consume_pending(ticket) if ticket else None + if normalized is None: + return JSONResponse( + { + "error": "invalid_request", + "error_description": ( + "This approval expired or was already used. " + "Start the authorization again." + ), + }, + status_code=400, + ) + audit.emit( + "auth.authorize.ok", client_id=normalized["client_id"], + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_redirect(normalized) + + # --- passkeys on the authorize page ---------------------------------- + + def _authorize_passkey_guard(request: Request) -> Response | None: + """Rate-limit + availability gate shared by the passkey endpoints.""" + if passkey_service is None or not passkey_service.available: + return JSONResponse( + {"ok": False, "error": "Passkeys are not available here."}, + status_code=503, + ) + ip = client_ip(request, tuple(config.server.trusted_proxies)) + if not _login_limiter.check(ip): + retry = _login_limiter.retry_after(ip) + return JSONResponse( + { + "ok": False, + "error": f"Too many attempts. Retry in {retry}s.", + }, + status_code=429, + ) + return None + + async def _authorize_json_body(request: Request) -> dict: + try: + data = await request.json() + except Exception: # noqa: BLE001 + return {} + return data if isinstance(data, dict) else {} + + async def oauth_passkey_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + client_id = str(body.get("client_id") or "").strip() + if not client_id or not client_store.exists(client_id): + return JSONResponse( + {"ok": False, "error": "Unknown client."}, status_code=400, + ) + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + try: + options, state = passkey_service.authentication_options( + request, client_id=_passkey_owner(client_id), + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse( + {"ok": False, "error": str(exc)}, status_code=400, + ) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + raw_params = body.get("params") + if not isinstance(raw_params, dict): + return JSONResponse( + {"ok": False, "error": "invalid_request"}, status_code=400, + ) + params = {k: v for k, v in raw_params.items() if isinstance(v, str)} + normalized, err = _validate_authorize_params(params) + if err is not None: + return JSONResponse( + {"ok": False, "error": "Invalid authorization request."}, + status_code=400, + ) + client_id = normalized["client_id"] + if totp_locked(client_id): + return JSONResponse( + {"ok": False, "error": "Too many attempts. Try again in 5 minutes."}, + status_code=429, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_authentication( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + audit.emit( + "auth.authorize.fail", client_id=client_id, + reason=f"passkey:{exc}", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return JSONResponse({"ok": False, "error": str(exc)}, status_code=401) + # The challenge was minted for the seed owner; make sure the + # credential that answered it really guards *this* client. + if record.client_id != _passkey_owner(client_id): + return JSONResponse( + {"ok": False, "error": "This passkey belongs to another client."}, + status_code=401, + ) + totp_record_success(client_id) + audit.emit( + "auth.authorize.2fa", client_id=client_id, via="passkey", + ip=client_ip(request, tuple(config.server.trusted_proxies)), + ) + return _authorize_approved(request, normalized, as_json=True) + + async def oauth_passkey_register_options(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + owner = _passkey_owner(normalized["client_id"]) + try: + options, state = passkey_service.registration_options( + request, + client_id=owner, + client_name=client_store.get_name(owner) or owner, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + return JSONResponse({"ok": True, "options": options, "state": state}) + + async def oauth_passkey_register_verify(request: Request) -> Response: + blocked = _authorize_passkey_guard(request) + if blocked is not None: + return blocked + body = await _authorize_json_body(request) + normalized = _peek_pending(str(body.get("ticket") or "")) + if normalized is None: + return JSONResponse( + {"ok": False, "error": "This approval expired. Start again."}, + status_code=400, + ) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return JSONResponse( + {"ok": False, "error": "Malformed passkey response."}, + status_code=400, + ) + try: + record = passkey_service.verify_registration( + request, state=state, credential=credential, + ) + except passkeys_mod.PasskeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + audit.emit( + "auth.passkey.register.ok", + client_id=record.client_id, label=record.label, + ) + return JSONResponse( + {"ok": True, "passkey": record.to_json()}, status_code=201, ) - query = {"code": code} - if normalized["state"]: - query["state"] = normalized["state"] - parsed = urlparse(redirect_uri) - sep = "&" if parsed.query else "?" - location = f"{redirect_uri}{sep}{urlencode(query)}" - return Response(status_code=302, headers={"Location": location}) async def oauth_token(request: Request) -> Response: ip = client_ip(request, tuple(config.server.trusted_proxies)) @@ -1085,6 +1921,7 @@ async def auth_middleware(request: Request, call_next): "/metrics", "/oauth/token", "/oauth/authorize", + "/oauth/authorize/finalize", "/oauth/register", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", @@ -1101,6 +1938,14 @@ async def auth_middleware(request: Request, call_next): ): return await call_next(request) + # Passkey ceremonies for /oauth/authorize. They are part of signing + # in, so they cannot require a bearer -- there isn't one yet. Their + # own guards are the WebAuthn signature, the TOTP lockout and the + # per-IP login limiter; enrolment additionally demands an approval + # ticket, which is only handed out after a second factor passed. + if path.startswith("/oauth/passkey/"): + return await call_next(request) + # Dashboard routes have their own session-based auth. if path.startswith("/app/"): return await call_next(request) @@ -1149,10 +1994,8 @@ async def auth_middleware(request: Request, call_next): # OAuth Dynamic Client Registration plumbing. Only engaged when both # the feature flag is set AND the dashboard is enabled (the slug store - # lives in the dashboard's SQLite db). - from . import dashboard as _dashboard_mod + # lives in the dashboard's SQLite db, opened above). dyn_reg_store = None - shared_database = None if config.server.allow_dynamic_registration: if not _dashboard_mod.is_enabled(): print( @@ -1162,9 +2005,8 @@ async def auth_middleware(request: Request, call_next): file=sys.stderr, ) sys.exit(1) - from .dashboard.db import Database as _Database from .dashboard.dyn_reg import DynamicSlugStore as _DynamicSlugStore - shared_database = _Database() + assert shared_database is not None # required path: creation raised otherwise dyn_reg_store = _DynamicSlugStore(shared_database) async def dcr_protected_resource_metadata(request: Request) -> Response: @@ -1337,6 +2179,7 @@ async def lifespan(_app): dyn_reg=dyn_reg_store, shared_database=shared_database, login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), + passkey_service=passkey_service, ) app = Starlette( @@ -1348,6 +2191,20 @@ async def lifespan(_app): Route("/.well-known/oauth-protected-resource/mcp", protected_resource_metadata), Route("/oauth/authorize", oauth_authorize_get, methods=["GET"]), Route("/oauth/authorize", oauth_authorize_post, methods=["POST"]), + Route( + "/oauth/authorize/finalize", + oauth_authorize_finalize, methods=["POST"], + ), + Route("/oauth/passkey/options", oauth_passkey_options, methods=["POST"]), + Route("/oauth/passkey/verify", oauth_passkey_verify, methods=["POST"]), + Route( + "/oauth/passkey/register/options", + oauth_passkey_register_options, methods=["POST"], + ), + Route( + "/oauth/passkey/register/verify", + oauth_passkey_register_verify, methods=["POST"], + ), Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/register", oauth_register, methods=["POST"]), *dcr_routes, @@ -1373,6 +2230,13 @@ async def lifespan(_app): print(f"Dashboard: http://{host}:{port}/app/login (chat: {chat_status})") else: print("Dashboard: disabled (BEACONMCP_DASHBOARD_ENABLED=false)") + # The login pages hide their passkey buttons when this is off, with no + # visible explanation -- so say it here, where the operator can see it. + passkey_reason = passkey_service.unavailable_reason + if passkey_reason is None: + print("Passkeys: enabled (also needs HTTPS, or a loopback host)") + else: + print(f"Passkeys: disabled - {passkey_reason}") if n_clients == 0: print("\nNo clients registered. Create one with: beaconmcp auth create --name 'My Client'") uvicorn.run(app, host=host, port=port, log_level="info") @@ -1381,7 +2245,8 @@ async def lifespan(_app): def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, - login_limiter=None, trusted_proxies=()): + login_limiter=None, trusted_proxies=(), + passkey_service=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -1454,6 +2319,7 @@ def _float_env(name: str, default: float) -> float: dyn_reg=dyn_reg, login_limiter=login_limiter, trusted_proxies=trusted_proxies, + passkeys=passkey_service, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 97ffec2..34b8f54 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -51,6 +51,7 @@ ) from .db import Database from .dyn_reg import DynamicSlugStore, SLUG_TTL_SECONDS +from .passkeys import PasskeyError, PasskeyService, is_secure_context from .session import SESSION_TTL_SECONDS, Session, SessionStore from .usage import UsageMeter, UsageStore @@ -98,6 +99,10 @@ class DashboardDeps: # tests/embedding paths can skip the limiter entirely. login_limiter: object | None = None trusted_proxies: tuple[str, ...] = () + # WebAuthn ceremonies for the passkey login/registration flow. When + # unset (or reporting ``available is False``), the login page hides + # every passkey affordance and the TOTP path is the only way in. + passkeys: PasskeyService | None = None # --------------------------------------------------------------------------- @@ -121,8 +126,17 @@ def _set_session_cookie(response: Response, session_id: str, secure: bool) -> No ) -def _set_csrf_cookie(response: Response, secure: bool) -> str: - token = csrf.issue_token() +def _set_csrf_cookie( + response: Response, secure: bool, token: str | None = None, +) -> str: + """Issue (or re-issue) the double-submit CSRF cookie. + + ``token`` lets a caller mint the value first so it can also hand it to + the client out-of-band -- the JSON login does that, because rotating + the cookie without telling the page would leave its in-DOM token stale + and every follow-up fetch would 403. + """ + token = token or csrf.issue_token() response.set_cookie( csrf.CSRF_COOKIE, token, @@ -243,6 +257,21 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _wants_json(request: Request) -> bool: + """True when the caller is the login page's fetch() rather than a form POST. + + The login form still works with JavaScript disabled -- it posts and gets + a redirect, exactly as before. The enhanced flow (shimmer, then the + post-2FA screen offering to enrol a passkey) needs to stay on the page, + so it opts in explicitly with this header. + """ + return request.headers.get("x-beaconmcp-mode", "") == "json" + + +def _passkeys_ready(deps: DashboardDeps) -> bool: + return deps.passkeys is not None and deps.passkeys.available + + # --------------------------------------------------------------------------- # Route factories # --------------------------------------------------------------------------- @@ -278,8 +307,72 @@ async def login_get(request: Request) -> Response: next=request.query_params.get("next", ""), banner=None, locked=False, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) + def _login_success_payload( + request: Request, session: Session, *, next_url: str, bearer_ttl: int, + ) -> dict[str, Any]: + """Data the post-2FA screen renders: expiry + passkey affordances.""" + return { + "ok": True, + "next": next_url, + "client_id": session.client_id, + "client_name": ( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + # Two different clocks, and users care about both: the MCP bearer + # is what actually stops working (a 2FA re-prompt at /app/refresh), + # while the signed-in cookie is what saves them the full login. + "bearer_expires_at": time.time() + bearer_ttl, + "session_expires_at": session.expires_at, + "passkeys_enabled": _passkeys_ready(deps), + "secure_context": is_secure_context(request), + "passkey_count": ( + deps.passkeys.store.count_for_client(session.client_id) + if _passkeys_ready(deps) else 0 + ), + } + + def _start_session( + request: Request, + *, + client_id: str, + client_secret: str, + next_url: str, + as_json: bool | None = None, + ) -> tuple[Session, int, Response]: + """Mint the MCP bearer + session row and attach the cookies. + + Shared by the TOTP login and the passkey login so the two paths + cannot drift on cookie flags or bearer lifetime. + """ + bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] + ua = request.headers.get("user-agent", "")[:200] + session = deps.session_store.create( + client_id=client_id, + client_secret=client_secret, + mcp_bearer=bearer, + bearer_ttl_seconds=ttl, + user_agent=ua, + ) + secure = _is_secure(request) + new_csrf = csrf.issue_token() + if as_json if as_json is not None else _wants_json(request): + payload = _login_success_payload( + request, session, next_url=next_url, bearer_ttl=ttl, + ) + payload["csrf_token"] = new_csrf + response: Response = JSONResponse(payload) + else: + response = RedirectResponse(next_url, status_code=303) + _set_session_cookie(response, session.session_id, secure) + _set_csrf_cookie(response, secure, new_csrf) + _apply_security_headers(response) + return session, ttl, response + async def login_post(request: Request) -> Response: if not await csrf.verify(request): return JSONResponse({"error": "csrf"}, status_code=403) @@ -292,14 +385,23 @@ async def login_post(request: Request) -> Response: ip = _client_ip(request, deps.trusted_proxies) if not limiter.check(ip): # type: ignore[attr-defined] retry = limiter.retry_after(ip) # type: ignore[attr-defined] + message = ( + f"Too many attempts from this address. Retry in {retry}s." + ) + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": True}, status=429, + ) return _render( "login.html", request, client_id="", next="", - banner=f"Too many attempts from this address. Retry in {retry}s.", + banner=message, locked=True, status_code=429, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) form = await request.form() @@ -316,6 +418,10 @@ def _v(name: str) -> str: next_url = _default_landing() def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: + if _wants_json(request): + return _json( + {"ok": False, "error": message, "locked": locked}, status=status, + ) return _render( "login.html", request, @@ -324,6 +430,8 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: banner=message, locked=locked, status_code=status, + passkeys_enabled=_passkeys_ready(deps), + secure_context=is_secure_context(request), ) if not client_id or not client_secret or not totp: @@ -356,21 +464,12 @@ def _fail(message: str, *, status: int = 400, locked: bool = False) -> Response: deps.totp_record_success(client_id) audit.emit("dashboard.login.ok", client_id=client_id) - bearer, ttl = deps.token_store.issue(client_id) # type: ignore[attr-defined] - ua = request.headers.get("user-agent", "")[:200] - session = deps.session_store.create( + _, _, response = _start_session( + request, client_id=client_id, client_secret=client_secret, - mcp_bearer=bearer, - bearer_ttl_seconds=ttl, - user_agent=ua, + next_url=next_url, ) - - secure = _is_secure(request) - response = RedirectResponse(next_url, status_code=303) - _set_session_cookie(response, session.session_id, secure) - _set_csrf_cookie(response, secure) - _apply_security_headers(response) return response async def refresh_get(request: Request) -> Response: @@ -772,6 +871,231 @@ async def tokens_revoke(request: Request) -> Response: ) return RedirectResponse("/app/tokens", status_code=303) + # --- Passkeys (WebAuthn) --------------------------------------------- + + def _passkey_service() -> PasskeyService | Response: + if not _passkeys_ready(deps): + return _json({"error": "passkeys_unavailable"}, status=503) + assert deps.passkeys is not None + return deps.passkeys + + async def api_passkeys_list(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + service = _passkey_service() + if isinstance(service, Response): + return service + return _json({ + "passkeys": [ + p.to_json() for p in service.store.list_for_client(session.client_id) + ], + }) + + async def api_passkeys_register_options(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + try: + options, state = service.registration_options( + request, + client_id=session.client_id, + client_name=( + deps.client_store.get_name(session.client_id) # type: ignore[attr-defined] + or session.client_id + ), + session_id=session.session_id, + ) + except PasskeyError as exc: + return _json({"error": str(exc)}, status=400) + return _json({"options": options, "state": state}) + + async def api_passkeys_register_verify(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential = body.get("credential") + state = str(body.get("state") or "") + if not isinstance(credential, dict) or not state: + return _json({"error": "invalid_request"}, status=400) + label_raw = body.get("label") + try: + record = service.verify_registration( + request, + state=state, + credential=credential, + label=label_raw if isinstance(label_raw, str) else None, + session_id=session.session_id, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.passkey.register.fail", + client_id=session.client_id, reason=str(exc), + ) + return _json({"error": str(exc)}, status=400) + audit.emit( + "dashboard.passkey.register.ok", + client_id=session.client_id, label=record.label, + ) + return _json({"ok": True, "passkey": record.to_json()}, status=201) + + async def api_passkeys_delete(request: Request) -> Response: + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + credential_id = str(body.get("credential_id") or "").strip() + if not credential_id: + return _json({"error": "invalid_request"}, status=400) + if not service.store.delete(credential_id, session.client_id): + return _json({"error": "not_found"}, status=404) + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return _json({"ok": True}) + + async def passkeys_remove(request: Request) -> Response: + """Form-POST twin of the JSON delete, for the tokens page. + + The tokens page is deliberately JS-free for destructive actions, so + removing a passkey posts a plain form and lands back on the page. + """ + session = _load_session(request, deps) + if not session: + return RedirectResponse("/app/login", status_code=302) + if not _bearer_live(deps, session): + return RedirectResponse("/app/refresh?next=/app/tokens", status_code=302) + if not await csrf.verify(request): + return JSONResponse({"error": "csrf"}, status_code=403) + if _passkeys_ready(deps): + form = await request.form() + raw = form.get("credential_id", "") + credential_id = (raw if isinstance(raw, str) else "").strip() + assert deps.passkeys is not None + if credential_id and deps.passkeys.store.delete( + credential_id, session.client_id, + ): + audit.emit("dashboard.passkey.revoke", client_id=session.client_id) + return RedirectResponse("/app/tokens", status_code=303) + + def _passkey_login_rate_limited(request: Request) -> Response | None: + """Apply the login limiter to the unauthenticated passkey endpoints. + + These take a client_id/client_secret pair, so they are just as much + a credential-stuffing surface as /app/login itself. + """ + limiter = deps.login_limiter + if limiter is None: + return None + from ..ratelimit import client_ip as _client_ip # local: avoid import cycle + ip = _client_ip(request, deps.trusted_proxies) + if limiter.check(ip): # type: ignore[attr-defined] + return None + retry = limiter.retry_after(ip) # type: ignore[attr-defined] + return _json( + { + "ok": False, + "error": f"Too many attempts from this address. Retry in {retry}s.", + }, + status=429, + ) + + async def api_passkeys_auth_options(request: Request) -> Response: + """Start a passkey sign-in. Requires the client credentials first. + + The passkey replaces the TOTP factor only: without a valid + client_id/client_secret we will not even disclose whether a client + has credentials registered. + """ + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + if not client_id or not client_secret: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + try: + options, state = service.authentication_options( + request, client_id=client_id, + ) + except PasskeyError as exc: + return _json({"ok": False, "error": str(exc)}, status=400) + return _json({"ok": True, "options": options, "state": state}) + + async def api_passkeys_auth_verify(request: Request) -> Response: + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + limited = _passkey_login_rate_limited(request) + if limited is not None: + return limited + service = _passkey_service() + if isinstance(service, Response): + return service + body = await _read_json(request) + client_id = str(body.get("client_id") or "").strip() + client_secret = str(body.get("client_secret") or "") + state = str(body.get("state") or "") + credential = body.get("credential") + if not client_id or not client_secret or not state: + return _json({"ok": False, "error": "Missing credentials."}, status=400) + if not isinstance(credential, dict): + return _json({"ok": False, "error": "Malformed passkey response."}, status=400) + # Re-verify the secret: the options call proved it once, but the + # state token alone must never be enough to mint a session. + if not deps.client_store.verify(client_id, client_secret): # type: ignore[attr-defined] + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + try: + record = service.verify_authentication( + request, state=state, credential=credential, + ) + except PasskeyError as exc: + audit.emit( + "dashboard.login.fail", client_id=client_id, + reason=f"passkey:{exc}", + ) + return _json({"ok": False, "error": str(exc)}, status=401) + if record.client_id != client_id: + return _json({"ok": False, "error": "Invalid credentials."}, status=401) + + next_url = str(body.get("next") or "").strip() + if not next_url.startswith("/app/"): + next_url = _default_landing() + # A passkey sign-in is a clean second factor: clear any TOTP + # lockout the same way a correct code would. + deps.totp_record_success(client_id) + audit.emit("dashboard.login.ok", client_id=client_id, via="passkey") + _, _, response = _start_session( + request, + client_id=client_id, + client_secret=client_secret, + next_url=next_url, + as_json=True, + ) + return response + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -1132,6 +1456,25 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/app/api/chat/stream", api_chat_stream, methods=["POST"]), Route("/app/api/chat/confirm", api_chat_confirm, methods=["POST"]), Route("/app/api/usage", api_usage, methods=["GET"]), + Route("/app/api/passkeys", api_passkeys_list, methods=["GET"]), + Route( + "/app/api/passkeys/register/options", + api_passkeys_register_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/register/verify", + api_passkeys_register_verify, methods=["POST"], + ), + Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), + Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route( + "/app/api/passkeys/auth/options", + api_passkeys_auth_options, methods=["POST"], + ), + Route( + "/app/api/passkeys/auth/verify", + api_passkeys_auth_verify, methods=["POST"], + ), Route("/", index, methods=["GET"]), Mount( "/app/static", @@ -1251,6 +1594,21 @@ def _expires_label(expires_at: float) -> str: locked=deps.totp_locked(session.client_id), chat_enabled=deps.engine is not None, dcr_enabled=deps.dyn_reg is not None, + passkeys_enabled=_passkeys_ready(deps), + passkeys=( + [ + { + "credential_id": p.credential_id, + "label": p.label, + "created_human": _human_time(p.created_at), + "last_used_human": ( + _human_time(p.last_used_at) if p.last_used_at else None + ), + } + for p in deps.passkeys.store.list_for_client(session.client_id) + ] + if _passkeys_ready(deps) else [] + ), ) diff --git a/src/beaconmcp/dashboard/db.py b/src/beaconmcp/dashboard/db.py index 7607758..dd80fd7 100644 --- a/src/beaconmcp/dashboard/db.py +++ b/src/beaconmcp/dashboard/db.py @@ -19,7 +19,7 @@ def db_path() -> Path: return Path(override) if override else DEFAULT_DB_PATH -_LATEST_VERSION = 4 +_LATEST_VERSION = 5 def _migrate(conn: sqlite3.Connection) -> None: @@ -147,6 +147,30 @@ def _migrate(conn: sqlite3.Connection) -> None: """ ) + if version < 5: + # WebAuthn credentials, one row per registered passkey. The public + # key and signature counter are all we need to verify assertions; + # nothing here is secret (a public key is public), but the table + # still lives in the 0600 dashboard database because knowing which + # credentials a client owns is useful to an attacker. + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS passkeys ( + credential_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT, + label TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + backed_up INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_passkeys_client + ON passkeys(client_id, created_at DESC); + """ + ) + conn.execute(f"PRAGMA user_version = {_LATEST_VERSION}") conn.commit() diff --git a/src/beaconmcp/dashboard/passkeys.py b/src/beaconmcp/dashboard/passkeys.py new file mode 100644 index 0000000..d80370f --- /dev/null +++ b/src/beaconmcp/dashboard/passkeys.py @@ -0,0 +1,598 @@ +"""Passkey (WebAuthn) support for the BeaconMCP login pages. + +A passkey stands in for the TOTP factor, never for the client secret. The +flow on both login pages stays two-factor: + +1. ``client_id`` + ``client_secret`` -- something you know / have stored. +2. Either a 6-digit TOTP code **or** a WebAuthn assertion -- something you + have on your device. + +That ordering is deliberate. The dashboard session record encrypts the +client secret so it can re-mint MCP bearers later, so a purely +"usernameless" passkey login could not build a usable session anyway. +Keeping the secret as the first factor also means a stolen passkey alone +is worthless. + +Credentials live in the dashboard SQLite database (``passkeys`` table); +challenges live in memory with a short TTL, since a challenge that +survives a restart is a replay window, not a feature. + +The ``webauthn`` package (py_webauthn) is an optional dependency: when it +is missing, :class:`PasskeyService` reports ``available is False`` and +every login page simply hides its passkey affordances instead of erroring. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - typing only + from starlette.requests import Request + + from .db import Database + +try: + import webauthn as _webauthn + from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, + ) +except ImportError: # pragma: no cover - exercised only on slim installs + _webauthn = None # type: ignore[assignment] + AuthenticatorSelectionCriteria = None # type: ignore[assignment,misc] + AuthenticatorTransport = None # type: ignore[assignment,misc] + PublicKeyCredentialDescriptor = None # type: ignore[assignment,misc] + ResidentKeyRequirement = None # type: ignore[assignment,misc] + UserVerificationRequirement = None # type: ignore[assignment,misc] + + +#: How long a registration/authentication challenge stays usable. The spec +#: suggests a couple of minutes; the browser prompt itself times out at 60 s. +CHALLENGE_TTL_SECONDS = 180 + +#: Relying-party display name shown in the OS passkey prompt. +RP_NAME = "BeaconMCP" + +#: Cap on credentials per client. High enough for phone + laptop + a +#: hardware key or two, low enough that the allowCredentials list stays sane. +MAX_PASSKEYS_PER_CLIENT = 10 + + +class PasskeyError(Exception): + """User-facing passkey failure. The message is safe to render.""" + + +def webauthn_installed() -> bool: + """True when the optional ``webauthn`` dependency is importable.""" + return _webauthn is not None + + +def b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +# --------------------------------------------------------------------------- +# Relying-party identity, derived from the live request +# --------------------------------------------------------------------------- + +def _forwarded_host(request: "Request") -> str: + host = request.headers.get("x-forwarded-host") or request.headers.get("host") + if not host: + host = request.url.netloc or "localhost" + # A proxy chain may append entries; the first one is the client-facing host. + return host.split(",")[0].strip() + + +def rp_id_for(request: "Request") -> str: + """Return the WebAuthn RP ID (effective domain, no scheme, no port). + + Derived from the request rather than configured, so a deployment that + moves behind a new hostname keeps working without a config edit. The + trade-off is that credentials are scoped to the hostname they were + registered under -- which is exactly the WebAuthn security model. + """ + host = _forwarded_host(request) + if host.startswith("["): # IPv6 literal: [::1]:8420 + closing = host.find("]") + if closing != -1: + return host[1:closing] + return host.rsplit(":", 1)[0] if ":" in host else host + + +def origin_for(request: "Request") -> str: + """Return the origin the browser will put in ``clientDataJSON``.""" + scheme = request.headers.get("x-forwarded-proto") or request.url.scheme + scheme = scheme.split(",")[0].strip() + return f"{scheme}://{_forwarded_host(request)}" + + +def is_secure_context(request: "Request") -> bool: + """True when the browser will expose ``navigator.credentials``. + + WebAuthn is gated on a secure context: HTTPS, or a loopback host. A + plain-HTTP LAN deployment silently has no passkey API at all, so the + UI needs to know before it offers the button. + """ + scheme = ( + request.headers.get("x-forwarded-proto") or request.url.scheme + ).split(",")[0].strip() + if scheme == "https": + return True + return rp_id_for(request) in ("localhost", "127.0.0.1", "::1") + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +@dataclass +class PasskeyRecord: + credential_id: str # base64url + client_id: str + public_key: bytes + sign_count: int + transports: list[str] + label: str + created_at: float + last_used_at: float | None + backed_up: bool + + def to_json(self) -> dict[str, Any]: + return { + "credential_id": self.credential_id, + "label": self.label, + "created_at": self.created_at, + "last_used_at": self.last_used_at, + "backed_up": self.backed_up, + "transports": self.transports, + } + + +class PasskeyStore: + """Thin wrapper around the ``passkeys`` table.""" + + def __init__(self, database: "Database") -> None: + self._db = database + + @staticmethod + def _row_to_record(row: Any) -> PasskeyRecord: + try: + transports = json.loads(row["transports"] or "[]") + except (TypeError, ValueError): + transports = [] + return PasskeyRecord( + credential_id=row["credential_id"], + client_id=row["client_id"], + public_key=row["public_key"], + sign_count=row["sign_count"], + transports=[t for t in transports if isinstance(t, str)], + label=row["label"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + backed_up=bool(row["backed_up"]), + ) + + def add( + self, + *, + credential_id: str, + client_id: str, + public_key: bytes, + sign_count: int, + transports: list[str], + label: str, + backed_up: bool, + ) -> PasskeyRecord: + now = time.time() + self._db.conn().execute( + """ + INSERT INTO passkeys ( + credential_id, client_id, public_key, sign_count, + transports, label, created_at, last_used_at, backed_up + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + """, + ( + credential_id, + client_id, + public_key, + sign_count, + json.dumps(transports), + label, + now, + 1 if backed_up else 0, + ), + ) + return PasskeyRecord( + credential_id=credential_id, + client_id=client_id, + public_key=public_key, + sign_count=sign_count, + transports=transports, + label=label, + created_at=now, + last_used_at=None, + backed_up=backed_up, + ) + + def list_for_client(self, client_id: str) -> list[PasskeyRecord]: + rows = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE client_id = ? + ORDER BY created_at DESC + """, + (client_id,), + ).fetchall() + return [self._row_to_record(r) for r in rows] + + def get(self, credential_id: str) -> PasskeyRecord | None: + row = self._db.conn().execute( + """ + SELECT credential_id, client_id, public_key, sign_count, transports, + label, created_at, last_used_at, backed_up + FROM passkeys WHERE credential_id = ? + """, + (credential_id,), + ).fetchone() + return self._row_to_record(row) if row is not None else None + + def count_for_client(self, client_id: str) -> int: + row = self._db.conn().execute( + "SELECT COUNT(*) AS n FROM passkeys WHERE client_id = ?", + (client_id,), + ).fetchone() + return int(row["n"]) if row is not None else 0 + + def touch(self, credential_id: str, sign_count: int) -> None: + self._db.conn().execute( + "UPDATE passkeys SET sign_count = ?, last_used_at = ? " + "WHERE credential_id = ?", + (sign_count, time.time(), credential_id), + ) + + def delete(self, credential_id: str, client_id: str) -> bool: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE credential_id = ? AND client_id = ?", + (credential_id, client_id), + ) + return bool(cur.rowcount) + + def delete_all_for_client(self, client_id: str) -> int: + cur = self._db.conn().execute( + "DELETE FROM passkeys WHERE client_id = ?", (client_id,) + ) + return cur.rowcount or 0 + + +# --------------------------------------------------------------------------- +# Challenges +# --------------------------------------------------------------------------- + +@dataclass +class _Challenge: + purpose: str # "register" | "authenticate" + challenge: bytes + client_id: str + session_id: str | None + expires_at: float + + +class ChallengeStore: + """In-memory, single-use challenge store. + + Deliberately not persisted: a challenge that outlives the process is a + replay window. A restart mid-ceremony just makes the user click again. + """ + + def __init__(self, ttl_seconds: int = CHALLENGE_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._items: dict[str, _Challenge] = {} + self._lock = threading.Lock() + + def issue( + self, + *, + purpose: str, + challenge: bytes, + client_id: str, + session_id: str | None = None, + ) -> str: + state = secrets.token_urlsafe(24) + with self._lock: + self._prune_locked() + self._items[state] = _Challenge( + purpose=purpose, + challenge=challenge, + client_id=client_id, + session_id=session_id, + expires_at=time.time() + self._ttl, + ) + return state + + def consume(self, state: str, purpose: str) -> _Challenge | None: + with self._lock: + self._prune_locked() + item = self._items.pop(state, None) + if item is None or item.purpose != purpose: + return None + if time.time() > item.expires_at: + return None + return item + + def _prune_locked(self) -> None: + now = time.time() + for key in [k for k, v in self._items.items() if now > v.expires_at]: + del self._items[key] + + +# --------------------------------------------------------------------------- +# Ceremony orchestration +# --------------------------------------------------------------------------- + +def default_label(user_agent: str) -> str: + """Best-effort human label for a freshly registered credential. + + Users never get asked to name their passkey during login -- one more + field in the middle of a sign-in is friction for no security gain -- so + we guess from the User-Agent and let them recognise it later. + """ + ua = (user_agent or "").lower() + if "iphone" in ua or "ipad" in ua or "ios" in ua: + return "iPhone / iPad" + if "android" in ua: + return "Android" + if "mac os" in ua or "macintosh" in ua: + return "Mac" + if "windows" in ua: + return "Windows" + if "linux" in ua: + return "Linux" + return "Passkey" + + +def _transport_descriptors(transports: list[str]) -> list[Any] | None: + """Map stored transport strings onto the library enum, dropping unknowns.""" + if not transports or AuthenticatorTransport is None: + return None + out = [] + for t in transports: + try: + out.append(AuthenticatorTransport(t)) + except ValueError: + continue + return out or None + + +class PasskeyService: + """Generates and verifies WebAuthn ceremonies against a :class:`PasskeyStore`.""" + + def __init__(self, store: PasskeyStore | None) -> None: + self._store = store + self._challenges = ChallengeStore() + + @property + def available(self) -> bool: + """True when passkeys can actually be used (library + storage present).""" + return _webauthn is not None and self._store is not None + + @property + def unavailable_reason(self) -> str | None: + """Why passkeys are off, or ``None`` when they work. + + The login pages hide their passkey affordances silently -- there is + nothing an anonymous visitor could do about it anyway -- so the + operator needs to be able to ask the question somewhere. This backs + the startup banner and ``beaconmcp doctor``. + """ + if _webauthn is None: + return ( + "the 'webauthn' Python package is missing " + "(pip install 'webauthn>=2,<4', or reinstall BeaconMCP)" + ) + if self._store is None: + return "no writable dashboard database to store credentials in" + return None + + @property + def store(self) -> PasskeyStore: + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + return self._store + + def _require(self) -> None: + if _webauthn is None: + raise PasskeyError( + "Passkey support requires the 'webauthn' package. " + "Reinstall BeaconMCP to pull it in." + ) + if self._store is None: + raise PasskeyError("Passkeys are not available on this deployment.") + + # --- registration ---------------------------------------------------- + + def registration_options( + self, + request: "Request", + *, + client_id: str, + client_name: str, + session_id: str | None = None, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.create``.""" + self._require() + existing = self.store.list_for_client(client_id) + if len(existing) >= MAX_PASSKEYS_PER_CLIENT: + raise PasskeyError( + f"This client already has {MAX_PASSKEYS_PER_CLIENT} passkeys. " + "Remove one before adding another." + ) + options = _webauthn.generate_registration_options( + rp_id=rp_id_for(request), + rp_name=RP_NAME, + user_id=client_id.encode("utf-8"), + user_name=client_id, + user_display_name=client_name or client_id, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + # Stops the authenticator offering to overwrite a passkey the + # client already registered on this device. + exclude_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in existing + ], + ) + state = self._challenges.issue( + purpose="register", + challenge=options.challenge, + client_id=client_id, + session_id=session_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_registration( + self, + request: "Request", + *, + state: str, + credential: dict[str, Any], + label: str | None = None, + session_id: str | None = None, + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.create`` result and persist it.""" + self._require() + pending = self._challenges.consume(state, "register") + if pending is None: + raise PasskeyError( + "This passkey request expired. Start the registration again." + ) + if pending.session_id is not None and pending.session_id != session_id: + raise PasskeyError("This passkey request belongs to another session.") + + try: + verified = _webauthn.verify_registration_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + raise PasskeyError(f"Passkey registration rejected: {exc}") from exc + + credential_id = b64url_encode(verified.credential_id) + if self.store.get(credential_id) is not None: + raise PasskeyError("This passkey is already registered.") + + transports = [] + raw_transports = (credential.get("response") or {}).get("transports") + if isinstance(raw_transports, list): + transports = [t for t in raw_transports if isinstance(t, str)] + + return self.store.add( + credential_id=credential_id, + client_id=pending.client_id, + public_key=verified.credential_public_key, + sign_count=verified.sign_count, + transports=transports, + label=(label or "").strip()[:60] + or default_label(request.headers.get("user-agent", "")), + backed_up=bool(getattr(verified, "credential_backed_up", False)), + ) + + # --- authentication -------------------------------------------------- + + def authentication_options( + self, request: "Request", *, client_id: str, + ) -> tuple[dict[str, Any], str]: + """Return ``(options_dict, state)`` for ``navigator.credentials.get``.""" + self._require() + credentials = self.store.list_for_client(client_id) + if not credentials: + raise PasskeyError( + "No passkey is registered for this client. Sign in with your " + "2FA code once, then add one." + ) + options = _webauthn.generate_authentication_options( + rp_id=rp_id_for(request), + allow_credentials=[ + PublicKeyCredentialDescriptor( + id=b64url_decode(p.credential_id), + transports=_transport_descriptors(p.transports), + ) + for p in credentials + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + state = self._challenges.issue( + purpose="authenticate", + challenge=options.challenge, + client_id=client_id, + ) + return json.loads(_webauthn.options_to_json(options)), state + + def verify_authentication( + self, request: "Request", *, state: str, credential: dict[str, Any], + ) -> PasskeyRecord: + """Verify a ``navigator.credentials.get`` result. + + Returns the credential that signed the challenge. The caller is + responsible for turning that into a session -- this module never + touches bearers or cookies. + """ + self._require() + pending = self._challenges.consume(state, "authenticate") + if pending is None: + raise PasskeyError( + "This passkey request expired. Try signing in again." + ) + + raw_id = credential.get("id") or credential.get("rawId") + if not isinstance(raw_id, str) or not raw_id: + raise PasskeyError("Malformed passkey response.") + record = self.store.get(raw_id) + if record is None: + raise PasskeyError("Unknown passkey.") + # The challenge was issued for one client; a credential belonging to + # another must never satisfy it, even if both are valid on their own. + if record.client_id != pending.client_id: + raise PasskeyError("This passkey belongs to a different client.") + + try: + verified = _webauthn.verify_authentication_response( + credential=credential, + expected_challenge=pending.challenge, + expected_rp_id=rp_id_for(request), + expected_origin=origin_for(request), + credential_public_key=record.public_key, + credential_current_sign_count=record.sign_count, + ) + except Exception as exc: # noqa: BLE001 - library raises many subtypes + # This also covers the clone signal: py_webauthn refuses any + # assertion whose signature counter did not move forward, as + # long as either side maintains one (platform authenticators + # commonly pin it at 0, and that stays legal). + raise PasskeyError(f"Passkey rejected: {exc}") from exc + + self.store.touch(record.credential_id, verified.new_sign_count) + record.sign_count = verified.new_sign_count + record.last_used_at = time.time() + return record diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index a9020c4..7488464 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2168,3 +2168,150 @@ a.cta:hover { background: var(--accent-hover); } /* Shared icon sprite placement */ .icon-sprite { position: absolute; width: 0; height: 0; } + +/* =============================================================== + PASSKEYS + POST-2FA SCREEN (login.html) + =============================================================== */ + +/* Shimmer: a highlight sweeping across the button while a request is in + flight. Prefers a real animation over a spinner so the button keeps its + size and the label can carry the status text. */ +.btn-primary.is-loading, +.btn-ghost.is-loading { + position: relative; + overflow: hidden; + cursor: progress; + opacity: 1; +} +.btn-primary.is-loading::after, +.btn-ghost.is-loading::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.38) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; + pointer-events: none; +} +.btn-ghost.is-loading::after { + background: linear-gradient( + 100deg, + transparent 20%, + var(--accent-soft) 50%, + transparent 80% + ); +} +@keyframes shimmer-sweep { + to { transform: translateX(100%); } +} +/* The trailing chevron/check is noise once the button says "Verifying…". */ +.btn-primary.is-loading .btn-icon { opacity: 0; } +@media (prefers-reduced-motion: reduce) { + .btn-primary.is-loading::after, + .btn-ghost.is-loading::after { animation: none; opacity: 0.25; } +} + +.btn-ghost { + width: 100%; + padding: 11px 16px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-soft); + color: var(--fg); + font-family: var(--font); font-weight: 550; font-size: 14px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + transition: border-color 160ms var(--ease-out), background 160ms var(--ease-out), + color 160ms var(--ease-out); +} +.btn-ghost:hover:not(:disabled) { + border-color: var(--accent-border); + background: var(--accent-softer); + color: var(--fg); +} +.btn-ghost:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-ghost svg { flex-shrink: 0; } + +.alt-divider { + display: flex; align-items: center; gap: 12px; + margin: 18px 0 14px; + color: var(--fg-faint); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; +} +.alt-divider::before, +.alt-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +.auth-card .hint { + margin: 10px 0 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-muted); + text-align: center; +} + +.success-mark { + width: 42px; height: 42px; + border-radius: 50%; + display: grid; place-items: center; + margin-bottom: 16px; + color: var(--success); + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 35%, var(--border)); + animation: pop-in 320ms var(--ease-out) both; +} +@keyframes pop-in { + from { opacity: 0; transform: scale(0.82); } + to { opacity: 1; transform: scale(1); } +} + +.expiry-card { + margin: 4px 0 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.expiry-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; + padding: 5px 0; +} +.expiry-row + .expiry-row { border-top: 1px solid var(--border-subtle); } +.expiry-row dt { + font-size: 12.5px; + color: var(--fg-muted); +} +.expiry-row dd { + margin: 0; + font-size: 13px; font-weight: 550; + color: var(--fg); + text-align: right; +} + +#passkey-add-block { margin-top: 18px; } +#step-3 .btn-primary { margin-top: 20px; } +#step-3 .banner { margin-bottom: 14px; } + +/* Registered passkeys list (dashboard "API tokens" page) */ +.passkey-row { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + padding: 10px 0; +} +.passkey-row + .passkey-row { border-top: 1px solid var(--border-subtle); } +.passkey-row .passkey-meta { min-width: 0; } +.passkey-row .passkey-name { + font-size: 13.5px; font-weight: 550; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index b1f2fce..5292230 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -1,10 +1,22 @@ -// Two-step login: Client ID / Secret → TOTP. -// Submits all three fields together once the 6-digit code is entered. +// Three-step login. +// +// 1. Client ID / Secret +// 2. TOTP code, or a passkey instead +// 3. Signed in: session lifetime, optional passkey enrolment, finish +// +// Step 3 only exists when JavaScript is on: the form still posts normally +// and gets a 303 to the landing page when it isn't, so the whole passkey +// layer is progressive enhancement over a flow that already worked. (function() { + "use strict"; + var form = document.getElementById("login-form"); if (!form) return; + + var card = document.querySelector(".auth-card"); var s1 = document.getElementById("step-1"); var s2 = document.getElementById("step-2"); + var s3 = document.getElementById("step-3"); var toggle = document.getElementById("toggle-pw"); var pw = document.getElementById("client_secret"); var cid = document.getElementById("client_id"); @@ -14,6 +26,112 @@ var verifyBtn = document.getElementById("verify-btn"); var inputs = document.querySelectorAll("#totp-inputs input"); var verifiedLabel = document.getElementById("client-verified"); + var csrfInput = document.getElementById("csrf-token"); + var step2Error = document.getElementById("step-2-error"); + var step3Error = document.getElementById("step-3-error"); + var step3Ok = document.getElementById("step-3-ok"); + var passkeyLoginBlock = document.getElementById("passkey-login-block"); + var passkeyLoginBtn = document.getElementById("passkey-login-btn"); + var passkeyAddBlock = document.getElementById("passkey-add-block"); + var addPasskeyBtn = document.getElementById("add-passkey-btn"); + var finishBtn = document.getElementById("finish-btn"); + + var passkeysEnabled = card && card.dataset.passkeysEnabled === "true"; + var secureContext = card && card.dataset.secureContext === "true"; + var passkeysUsable = passkeysEnabled && secureContext && + window.BeaconPasskeys && window.BeaconPasskeys.supported(); + + var session = null; // payload returned by the successful login + var busy = false; + + if (passkeyLoginBlock && !passkeysUsable) passkeyLoginBlock.hidden = true; + // Server offers passkeys but the browser won't expose the API (insecure + // origin): say so, rather than silently dropping the button. + var passkeyUnsupported = document.getElementById("passkey-unsupported"); + if (passkeyUnsupported) { + passkeyUnsupported.hidden = passkeysUsable || !passkeysEnabled; + } + + // --- small helpers ---------------------------------------------------- + + function csrf() { + return csrfInput ? csrfInput.value : ""; + } + + function postJson(url, body) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: JSON.stringify(body || {}), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }); + } + + function showError(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + function showOk(el, message) { + if (!el) return; + el.textContent = message; + el.hidden = !message; + } + + // Shimmer: the button keeps its width, gains a sweeping highlight and + // swaps its label. Kept as a class so the CSS owns the animation. + function setLoading(btn, loading, label) { + if (!btn) return; + var labelEl = btn.querySelector(".btn-label"); + if (loading) { + if (labelEl && label) { + if (!btn.dataset.idleLabel) btn.dataset.idleLabel = labelEl.textContent; + labelEl.textContent = label; + } + btn.classList.add("is-loading"); + btn.disabled = true; + } else { + if (labelEl && btn.dataset.idleLabel) { + labelEl.textContent = btn.dataset.idleLabel; + delete btn.dataset.idleLabel; + } + btn.classList.remove("is-loading"); + } + } + + function formatDateTime(epochSeconds) { + if (!epochSeconds || !isFinite(epochSeconds)) return "—"; + var d = new Date(epochSeconds * 1000); + var today = new Date(); + var sameDay = d.toDateString() === today.toDateString(); + var time = d.toLocaleTimeString(undefined, { + hour: "2-digit", minute: "2-digit", + }); + if (sameDay) return "today at " + time; + return d.toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + }) + " at " + time; + } + + function formatRelative(epochSeconds) { + var secs = epochSeconds - (Date.now() / 1000); + if (secs <= 0) return "expired"; + var hours = secs / 3600; + if (hours < 1) return "in " + Math.max(1, Math.round(secs / 60)) + " min"; + if (hours < 48) return "in " + Math.round(hours) + " h"; + return "in " + Math.round(hours / 24) + " days"; + } + + // --- step 1 -> step 2 ------------------------------------------------- if (toggle) { toggle.addEventListener("click", function() { @@ -24,13 +142,14 @@ function goStep2() { if (!cid.value.trim() || !pw.value) return; if (verifiedLabel) verifiedLabel.textContent = cid.value.trim(); + showError(step2Error, ""); s1.hidden = true; s2.hidden = false; setTimeout(function() { if (inputs[0]) inputs[0].focus(); }, 40); } if (toStep2) toStep2.addEventListener("click", goStep2); - // Allow Enter in step-1 inputs to advance rather than submit. + // Enter in step-1 advances rather than submitting a half-filled form. [cid, pw].forEach(function(el) { if (!el) return; el.addEventListener("keydown", function(e) { @@ -46,15 +165,18 @@ s1.hidden = false; }); + // --- TOTP boxes ------------------------------------------------------- + function collectTotp() { var s = ""; inputs.forEach(function(i) { s += (i.value || "").replace(/\D/g, ""); }); return s; } + function refresh() { var v = collectTotp(); totpHidden.value = v; - verifyBtn.disabled = v.length !== 6; + verifyBtn.disabled = busy || v.length !== 6; } inputs.forEach(function(inp, i) { @@ -70,6 +192,14 @@ refresh(); }); inp.addEventListener("keydown", function(e) { + // Enter validates as soon as the six digits are in — no reaching + // for the mouse after typing the last one. + if (e.key === "Enter") { + e.preventDefault(); + refresh(); + if (!busy && collectTotp().length === 6) submitTotp(); + return; + } if (e.key === "Backspace" && !e.target.value && inputs[i - 1]) { inputs[i - 1].focus(); inputs[i - 1].value = ""; @@ -89,10 +219,201 @@ }); }); + function clearTotp() { + inputs.forEach(function(i) { i.value = ""; i.classList.remove("filled"); }); + totpHidden.value = ""; + refresh(); + if (inputs[0]) inputs[0].focus(); + } + + // --- step 3 ----------------------------------------------------------- + + function showStep3(payload) { + session = payload; + // Signing in rotates the CSRF cookie; without picking the new value up + // every follow-up fetch (passkey enrolment) would 403. + if (payload.csrf_token && csrfInput) csrfInput.value = payload.csrf_token; + s1.hidden = true; + s2.hidden = true; + s3.hidden = false; + + var who = document.getElementById("success-client"); + if (who) who.textContent = payload.client_name || payload.client_id || "—"; + + var bearerEl = document.getElementById("bearer-expiry"); + if (bearerEl) { + bearerEl.textContent = formatDateTime(payload.bearer_expires_at) + + " (" + formatRelative(payload.bearer_expires_at) + ")"; + } + var sessionEl = document.getElementById("session-expiry"); + if (sessionEl) sessionEl.textContent = formatDateTime(payload.session_expires_at); + + if (passkeyAddBlock) { + var canAdd = !!(payload.passkeys_enabled && payload.secure_context && + window.BeaconPasskeys && window.BeaconPasskeys.supported()); + passkeyAddBlock.hidden = !canAdd; + var addNote = document.getElementById("passkey-add-unsupported"); + if (addNote) addNote.hidden = canAdd || !payload.passkeys_enabled; + var hint = document.getElementById("passkey-add-hint"); + if (canAdd && hint && payload.passkey_count > 0) { + hint.textContent = payload.passkey_count === 1 + ? "1 passkey already registered. Add another for a second device." + : payload.passkey_count + " passkeys already registered. " + + "Add another for a second device."; + } + } + if (finishBtn) finishBtn.focus(); + } + + function finish() { + var next = (session && session.next) || "/app/tokens"; + setLoading(finishBtn, true, "Opening…"); + window.location.href = next; + } + + if (finishBtn) finishBtn.addEventListener("click", finish); + + // --- TOTP submit ------------------------------------------------------ + + function submitTotp() { + if (busy) return; + var code = collectTotp(); + if (code.length !== 6) return; + busy = true; + showError(step2Error, ""); + setLoading(verifyBtn, true, "Verifying…"); + + var body = new URLSearchParams(); + body.set("csrf_token", csrf()); + body.set("client_id", cid.value.trim()); + body.set("client_secret", pw.value); + body.set("totp", code); + var nextField = form.querySelector('input[name="next"]'); + if (nextField) body.set("next", nextField.value); + + fetch("/app/login", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrf(), + "X-BeaconMCP-Mode": "json", + }, + body: body.toString(), + }).then(function(res) { + return res.json().catch(function() { return {}; }).then(function(data) { + return { ok: res.ok, data: data }; + }); + }).then(function(res) { + busy = false; + setLoading(verifyBtn, false); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Sign-in failed."); + clearTotp(); + }).catch(function() { + busy = false; + setLoading(verifyBtn, false); + showError(step2Error, "Network error. Try again."); + refresh(); + }); + } + form.addEventListener("submit", function(e) { + e.preventDefault(); if (totpHidden.value.length !== 6) { - e.preventDefault(); goStep2(); + return; } + submitTotp(); }); + + // --- passkey sign-in (step 2) ---------------------------------------- + + if (passkeyLoginBtn) { + passkeyLoginBtn.addEventListener("click", function() { + if (busy) return; + var clientId = cid.value.trim(); + var secret = pw.value; + if (!clientId || !secret) { + showError(step2Error, "Enter your client credentials first."); + return; + } + busy = true; + showError(step2Error, ""); + setLoading(passkeyLoginBtn, true, "Waiting for your passkey…"); + verifyBtn.disabled = true; + + var nextField = form.querySelector('input[name="next"]'); + postJson("/app/api/passkeys/auth/options", { + client_id: clientId, + client_secret: secret, + }).then(function(res) { + if (!res.ok || !res.data.ok) { + throw new Error(res.data.error || "Passkey sign-in unavailable."); + } + return window.BeaconPasskeys.authenticate(res.data.options) + .then(function(assertion) { + return postJson("/app/api/passkeys/auth/verify", { + client_id: clientId, + client_secret: secret, + state: res.data.state, + credential: assertion, + next: nextField ? nextField.value : "", + }); + }); + }).then(function(res) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + if (res.ok && res.data && res.data.ok) { + showStep3(res.data); + return; + } + showError(step2Error, (res.data && res.data.error) || "Passkey rejected."); + }).catch(function(err) { + busy = false; + setLoading(passkeyLoginBtn, false); + refresh(); + showError(step2Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } + + // --- passkey enrolment (step 3) -------------------------------------- + + if (addPasskeyBtn) { + addPasskeyBtn.addEventListener("click", function() { + showError(step3Error, ""); + showOk(step3Ok, ""); + setLoading(addPasskeyBtn, true, "Follow your device prompt…"); + + postJson("/app/api/passkeys/register/options", {}).then(function(res) { + if (!res.ok) throw new Error(res.data.error || "Could not start registration."); + return window.BeaconPasskeys.register(res.data.options) + .then(function(attestation) { + return postJson("/app/api/passkeys/register/verify", { + state: res.data.state, + credential: attestation, + }); + }); + }).then(function(res) { + setLoading(addPasskeyBtn, false); + if (res.ok && res.data && res.data.ok) { + showOk(step3Ok, "Passkey “" + res.data.passkey.label + + "” registered. Next time you can skip the 2FA code."); + addPasskeyBtn.disabled = true; + var hint = document.getElementById("passkey-add-hint"); + if (hint) hint.hidden = true; + return; + } + showError(step3Error, (res.data && res.data.error) || "Registration failed."); + }).catch(function(err) { + setLoading(addPasskeyBtn, false); + showError(step3Error, window.BeaconPasskeys.describeError(err)); + }); + }); + } })(); diff --git a/src/beaconmcp/dashboard/static/webauthn.js b/src/beaconmcp/dashboard/static/webauthn.js new file mode 100644 index 0000000..1a079ba --- /dev/null +++ b/src/beaconmcp/dashboard/static/webauthn.js @@ -0,0 +1,119 @@ +// Minimal WebAuthn plumbing shared by the dashboard auth pages. +// +// The server speaks base64url (that's what py_webauthn's options_to_json +// emits and what its verifiers parse); the browser API speaks ArrayBuffer. +// Everything here is that translation, plus the two ceremony wrappers. +window.BeaconPasskeys = (function() { + "use strict"; + + function supported() { + return !!(window.PublicKeyCredential && navigator.credentials && + navigator.credentials.create && navigator.credentials.get); + } + + function b64urlToBuf(value) { + var s = String(value).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + var bin = window.atob(s); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + function bufToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ""; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return window.btoa(bin) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function decodeDescriptors(list) { + return (list || []).map(function(d) { + var out = { type: d.type || "public-key", id: b64urlToBuf(d.id) }; + if (d.transports && d.transports.length) out.transports = d.transports; + return out; + }); + } + + // navigator.credentials.create() — returns a JSON-safe attestation. + function register(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.user = Object.assign({}, options.user, { + id: b64urlToBuf(options.user.id), + }); + publicKey.excludeCredentials = decodeDescriptors(options.excludeCredentials); + return navigator.credentials.create({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No credential was created."); + var response = cred.response; + var out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: bufToB64url(response.attestationObject), + }, + }; + if (cred.authenticatorAttachment) { + out.authenticatorAttachment = cred.authenticatorAttachment; + } + if (response.getTransports) { + try { out.response.transports = response.getTransports(); } catch (e) {} + } + return out; + }); + } + + // navigator.credentials.get() — returns a JSON-safe assertion. + function authenticate(options) { + var publicKey = Object.assign({}, options); + publicKey.challenge = b64urlToBuf(options.challenge); + publicKey.allowCredentials = decodeDescriptors(options.allowCredentials); + return navigator.credentials.get({ publicKey: publicKey }).then(function(cred) { + if (!cred) throw new Error("No passkey was selected."); + var response = cred.response; + return { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults + ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + authenticatorData: bufToB64url(response.authenticatorData), + signature: bufToB64url(response.signature), + userHandle: response.userHandle ? bufToB64url(response.userHandle) : null, + }, + }; + }); + } + + // The browser throws opaque DOMExceptions; turn the ones users actually + // hit into something actionable and let the rest through as-is. + function describeError(err) { + if (!err) return "Passkey request failed."; + if (err.name === "NotAllowedError") { + return "Passkey prompt cancelled or timed out."; + } + if (err.name === "InvalidStateError") { + return "This device already has a passkey registered for this client."; + } + if (err.name === "SecurityError") { + return "Passkeys need a secure origin (HTTPS or localhost)."; + } + return err.message || String(err); + } + + return { + supported: supported, + register: register, + authenticate: authenticate, + describeError: describeError, + b64urlToBuf: b64urlToBuf, + bufToB64url: bufToB64url, + }; +})(); diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 0aa7c94..41684f2 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -2,13 +2,15 @@ {% block title %}Sign in · BeaconMCP{% endblock %} {% block body_class %}auth-page{% endblock %} {% block body %} -
+
BeaconMCP
- + {% if next %}{% endif %} {# Step 1 — Client ID + Client Secret #} @@ -58,7 +60,7 @@

Sign in

- {# Step 2 — TOTP #} + {# Step 2 — TOTP, or a passkey instead #}
+ +
@@ -84,12 +88,70 @@

Two-factor

+ + {% if passkeys_enabled %} +
+
or
+ +

Skip the 6-digit code with a passkey registered on this device.

+
+ + {% endif %} + + {# Step 3 — signed in: session lifetime, passkey enrolment, finish #} +
+ {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/tokens.html b/src/beaconmcp/dashboard/templates/tokens.html index 121fe70..032a97a 100644 --- a/src/beaconmcp/dashboard/templates/tokens.html +++ b/src/beaconmcp/dashboard/templates/tokens.html @@ -259,6 +259,39 @@

Static bearer tokens

+ {% if passkeys_enabled %} +
+
Passkeys
+ {% if passkeys %} +
+ {% for p in passkeys %} +
+
+
{{ p.label }}
+
+ Added {{ p.created_human }}{% if p.last_used_human %} · last used {{ p.last_used_human }}{% else %} · never used{% endif %} +
+
+
+ + + +
+
+ {% endfor %} +
+ {% else %} +

+ No passkey registered. Add one right after your next sign-in to skip + the 2FA code on this device. +

+ {% endif %} +
+ {% endif %} +

Concrete setup per client is in the By platform tab.

diff --git a/tests/test_dashboard_unit.py b/tests/test_dashboard_unit.py index 65091af..5acd498 100644 --- a/tests/test_dashboard_unit.py +++ b/tests/test_dashboard_unit.py @@ -704,8 +704,10 @@ def test_migration_v1_to_v2_renames_gemini_models(tmp_path): assert msg["model"] == "gemini-3-flash-preview" # user_version reflects the migration (latest schema version). + from beaconmcp.dashboard.db import _LATEST_VERSION + ver = db.conn().execute("PRAGMA user_version").fetchone()[0] - assert ver == 4 + assert ver == _LATEST_VERSION def test_short_ciphertext_decryption_returns_none(store): diff --git a/tests/test_passkeys.py b/tests/test_passkeys.py new file mode 100644 index 0000000..06279ac --- /dev/null +++ b/tests/test_passkeys.py @@ -0,0 +1,808 @@ +"""Passkey (WebAuthn) tests. + +Drives the real ceremonies end to end against a software authenticator +built here from ``cryptography`` primitives: a fake that only returned +canned dicts would prove nothing, since every interesting failure mode +(wrong origin, replayed challenge, foreign credential) lives inside the +signature verification. + +Run with:: + + pytest tests/test_passkeys.py -v +""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +import sys +import time +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +pytest.importorskip("webauthn", reason="passkey support is an optional extra") +cbor2 = pytest.importorskip("cbor2") + +from cryptography.hazmat.primitives import hashes # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ec # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.utils import ( # noqa: E402 + decode_dss_signature, + encode_dss_signature, +) + +from beaconmcp.auth import TotpResult # noqa: E402 +from beaconmcp.dashboard.app import ( # noqa: E402 + DashboardDeps, + SESSION_COOKIE, + build_dashboard_routes, +) +from beaconmcp.dashboard.csrf import CSRF_COOKIE # noqa: E402 +from beaconmcp.dashboard.db import Database # noqa: E402 +from beaconmcp.dashboard.passkeys import ( # noqa: E402 + ChallengeStore, + PasskeyError, + PasskeyService, + PasskeyStore, + b64url_decode, + b64url_encode, + default_label, +) +from beaconmcp.dashboard.session import SessionStore # noqa: E402 + + +ORIGIN = "https://beacon.example" +RP_ID = "beacon.example" + + +# --------------------------------------------------------------------------- +# Software authenticator +# --------------------------------------------------------------------------- + +class SoftwareAuthenticator: + """A minimal ES256 authenticator: enough to produce valid ceremonies.""" + + AAGUID = b"\x00" * 16 + + def __init__(self) -> None: + self.key = ec.generate_private_key(ec.SECP256R1()) + self.credential_id = os.urandom(32) + self.sign_count = 0 + + # --- helpers --------------------------------------------------------- + + def _cose_key(self) -> bytes: + numbers = self.key.public_key().public_numbers() + return cbor2.dumps({ + 1: 2, # kty: EC2 + 3: -7, # alg: ES256 + -1: 1, # crv: P-256 + -2: numbers.x.to_bytes(32, "big"), + -3: numbers.y.to_bytes(32, "big"), + }) + + @staticmethod + def _client_data(kind: str, challenge: str, origin: str) -> bytes: + return json.dumps( + { + "type": kind, + "challenge": challenge, + "origin": origin, + "crossOrigin": False, + }, + separators=(",", ":"), + ).encode("utf-8") + + def _auth_data(self, rp_id: str, flags: int, attested: bool) -> bytes: + data = hashlib.sha256(rp_id.encode("utf-8")).digest() + data += bytes([flags]) + data += struct.pack(">I", self.sign_count) + if attested: + key = self._cose_key() + data += self.AAGUID + data += struct.pack(">H", len(self.credential_id)) + data += self.credential_id + data += key + return data + + # --- ceremonies ------------------------------------------------------ + + def create(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + client_data = self._client_data( + "webauthn.create", options["challenge"], origin, + ) + # UP | UV | AT + auth_data = self._auth_data(rp_id, 0x01 | 0x04 | 0x40, attested=True) + attestation = cbor2.dumps({ + "fmt": "none", "attStmt": {}, "authData": auth_data, + }) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "attestationObject": b64url_encode(attestation), + "transports": ["internal"], + }, + } + + def get(self, options: dict, *, origin: str = ORIGIN, rp_id: str = RP_ID) -> dict: + self.sign_count += 1 + client_data = self._client_data("webauthn.get", options["challenge"], origin) + auth_data = self._auth_data(rp_id, 0x01 | 0x04, attested=False) + payload = auth_data + hashlib.sha256(client_data).digest() + raw = self.key.sign(payload, ec.ECDSA(hashes.SHA256())) + # Re-encode so the DER is canonical whatever the backend produced. + r, s = decode_dss_signature(raw) + signature = encode_dss_signature(r, s) + return { + "id": b64url_encode(self.credential_id), + "rawId": b64url_encode(self.credential_id), + "type": "public-key", + "clientExtensionResults": {}, + "response": { + "clientDataJSON": b64url_encode(client_data), + "authenticatorData": b64url_encode(auth_data), + "signature": b64url_encode(signature), + "userHandle": None, + }, + } + + +class FakeRequest: + """Just the surface :mod:`beaconmcp.dashboard.passkeys` reads.""" + + def __init__(self, host: str = RP_ID, scheme: str = "https") -> None: + self.headers = {"host": host, "x-forwarded-proto": scheme} + + class _URL: + netloc = host + + self.url = _URL() + self.url.scheme = scheme # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def store(tmp_path) -> PasskeyStore: + return PasskeyStore(Database(tmp_path / "dashboard.db")) + + +@pytest.fixture() +def service(store) -> PasskeyService: + return PasskeyService(store) + + +@pytest.fixture() +def enrolled(service): + """A client with one registered passkey, plus its authenticator.""" + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + return auth, record + + +# --------------------------------------------------------------------------- +# RP identity +# --------------------------------------------------------------------------- + +def test_rp_id_strips_port_and_scheme(): + from beaconmcp.dashboard.passkeys import origin_for, rp_id_for + + req = FakeRequest(host="beacon.example:8420") + assert rp_id_for(req) == "beacon.example" + assert origin_for(req) == "https://beacon.example:8420" + + +def test_rp_id_handles_ipv6_literal(): + from beaconmcp.dashboard.passkeys import rp_id_for + + assert rp_id_for(FakeRequest(host="[::1]:8420")) == "::1" + + +def test_secure_context_requires_https_or_loopback(): + from beaconmcp.dashboard.passkeys import is_secure_context + + assert is_secure_context(FakeRequest(host="beacon.example", scheme="https")) + assert is_secure_context(FakeRequest(host="localhost:8420", scheme="http")) + assert not is_secure_context(FakeRequest(host="192.168.1.5:8420", scheme="http")) + + +def test_forwarded_host_wins_over_host(): + from beaconmcp.dashboard.passkeys import rp_id_for + + req = FakeRequest(host="internal:8420") + req.headers["x-forwarded-host"] = "public.example" + assert rp_id_for(req) == "public.example" + + +def test_default_label_from_user_agent(): + assert default_label("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)") == "iPhone / iPad" + assert default_label("Mozilla/5.0 (Windows NT 10.0)") == "Windows" + assert default_label("") == "Passkey" + + +# --------------------------------------------------------------------------- +# Challenge store +# --------------------------------------------------------------------------- + +def test_challenge_is_single_use(): + challenges = ChallengeStore() + state = challenges.issue( + purpose="authenticate", challenge=b"abc", client_id="c1", + ) + assert challenges.consume(state, "authenticate") is not None + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_purpose_must_match(): + challenges = ChallengeStore() + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "authenticate") is None + + +def test_challenge_expires(): + challenges = ChallengeStore(ttl_seconds=-1) + state = challenges.issue(purpose="register", challenge=b"abc", client_id="c1") + assert challenges.consume(state, "register") is None + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_registration_round_trip(service, store): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + assert options["rp"]["id"] == RP_ID + assert b64url_decode(options["user"]["id"]) == b"beaconmcp_test" + + record = service.verify_registration( + request, state=state, credential=auth.create(options), label="My laptop", + ) + assert record.client_id == "beaconmcp_test" + assert record.label == "My laptop" + assert record.transports == ["internal"] + assert store.count_for_client("beaconmcp_test") == 1 + + +def test_registration_rejects_wrong_origin(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test Client", + ) + with pytest.raises(PasskeyError): + service.verify_registration( + request, + state=state, + credential=auth.create(options, origin="https://evil.example"), + ) + + +def test_registration_state_bound_to_session(service): + request = FakeRequest() + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + session_id="session-a", + ) + with pytest.raises(PasskeyError, match="another session"): + service.verify_registration( + request, state=state, credential=auth.create(options), + session_id="session-b", + ) + + +def test_registration_excludes_known_credentials(service, enrolled): + _, record = enrolled + options, _ = service.registration_options( + FakeRequest(), client_id="beaconmcp_test", client_name="Test", + ) + assert [c["id"] for c in options["excludeCredentials"]] == [record.credential_id] + + +def test_registration_label_defaults_from_user_agent(service): + request = FakeRequest() + request.headers["user-agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)" + auth = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_test", client_name="Test", + ) + record = service.verify_registration( + request, state=state, credential=auth.create(options), + ) + assert record.label == "Mac" + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +def test_authentication_round_trip(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assert [c["id"] for c in options["allowCredentials"]] == [record.credential_id] + + verified = service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + assert verified.client_id == "beaconmcp_test" + assert store.get(record.credential_id).last_used_at is not None + + +def test_authentication_without_credentials_is_refused(service): + with pytest.raises(PasskeyError, match="No passkey"): + service.authentication_options(FakeRequest(), client_id="nobody") + + +def test_authentication_rejects_replayed_challenge(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + assertion = auth.get(options) + service.verify_authentication(request, state=state, credential=assertion) + with pytest.raises(PasskeyError, match="expired"): + service.verify_authentication(request, state=state, credential=assertion) + + +def test_authentication_rejects_foreign_credential(service, enrolled): + """A challenge minted for client A must not accept client B's passkey.""" + auth_a, _ = enrolled + request = FakeRequest() + + other = SoftwareAuthenticator() + options, state = service.registration_options( + request, client_id="beaconmcp_other", client_name="Other", + ) + service.verify_registration( + request, state=state, credential=other.create(options), + ) + + options, state = service.authentication_options( + request, client_id="beaconmcp_other", + ) + # Sign the *other* client's challenge with the first client's key. + with pytest.raises(PasskeyError, match="different client"): + service.verify_authentication( + request, state=state, credential=auth_a.get(options), + ) + + +def test_authentication_rejects_wrong_rp_id(service, enrolled): + auth, _ = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError): + service.verify_authentication( + request, state=state, + credential=auth.get(options, rp_id="evil.example"), + ) + + +def test_sign_counter_regression_is_rejected(service, enrolled, store): + auth, record = enrolled + request = FakeRequest() + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + service.verify_authentication(request, state=state, credential=auth.get(options)) + + # Roll the authenticator's counter back, as a cloned key would. + auth.sign_count = 0 + options, state = service.authentication_options( + request, client_id="beaconmcp_test", + ) + with pytest.raises(PasskeyError, match="sign count"): + service.verify_authentication( + request, state=state, credential=auth.get(options), + ) + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def test_delete_is_scoped_to_owner(store, service, enrolled): + _, record = enrolled + assert store.delete(record.credential_id, "someone_else") is False + assert store.delete(record.credential_id, "beaconmcp_test") is True + assert store.count_for_client("beaconmcp_test") == 0 + + +def test_service_without_store_reports_unavailable(): + service = PasskeyService(None) + assert service.available is False + assert "database" in service.unavailable_reason + with pytest.raises(PasskeyError): + service.authentication_options(FakeRequest(), client_id="x") + + +def test_working_service_has_no_unavailable_reason(service): + assert service.available is True + assert service.unavailable_reason is None + + +def test_missing_library_is_reported_with_a_fix(monkeypatch, store): + """The banner/doctor message must name the package and the command.""" + import beaconmcp.dashboard.passkeys as mod + + monkeypatch.setattr(mod, "_webauthn", None) + assert mod.webauthn_installed() is False + reason = PasskeyService(store).unavailable_reason + assert "webauthn" in reason and "pip install" in reason + + +# --------------------------------------------------------------------------- +# Dashboard routes +# --------------------------------------------------------------------------- + +class FakeClientStore: + def __init__(self): + self.clients = { + "beaconmcp_test": { + "secret": "sk_test", "name": "Test Client", "totp": "123456", + } + } + + def verify(self, client_id, secret): + c = self.clients.get(client_id) + return bool(c and c["secret"] == secret) + + def check_totp(self, client_id, code): + c = self.clients.get(client_id) + return TotpResult.OK if (c and c["totp"] == code) else TotpResult.INVALID + + def get_name(self, client_id): + c = self.clients.get(client_id) + return c["name"] if c else None + + +class FakeTokenStore: + def __init__(self): + self._tokens: dict[str, str] = {} + self._n = 0 + + def issue(self, client_id, name=None): + self._n += 1 + token = f"bearer_{self._n}" + self._tokens[token] = client_id + return token, 86400 + + def validate(self, token): + return self._tokens.get(token) + + def revoke(self, token): + self._tokens.pop(token, None) + return True + + def list_named(self, client_id): + return [] + + +@pytest.fixture() +def web(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "dashboard.db")) + db = Database(tmp_path / "dashboard.db") + passkey_store = PasskeyStore(db) + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=PasskeyService(passkey_store), + ) + app = Starlette(routes=build_dashboard_routes(deps)) + client = TestClient( + app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + return client, deps, passkey_store + + +def _csrf(client) -> str: + client.get("/app/login") + return client.cookies.get(CSRF_COOKIE) + + +def _json_headers(token: str) -> dict[str, str]: + return {"X-CSRF-Token": token, "X-BeaconMCP-Mode": "json"} + + +def _login(client) -> str: + """Sign in with TOTP and return the *rotated* CSRF token.""" + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + return res.json()["csrf_token"] + + +def test_login_json_mode_returns_expiry_and_passkey_state(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + headers=_json_headers(token), + ) + assert res.status_code == 200 + body = res.json() + assert body["ok"] is True + assert body["client_name"] == "Test Client" + assert body["bearer_expires_at"] > time.time() + assert body["session_expires_at"] > body["bearer_expires_at"] + assert body["passkeys_enabled"] is True + assert body["passkey_count"] == 0 + assert client.cookies.get(SESSION_COOKIE) + + +def test_login_form_mode_still_redirects(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "123456", + }, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + + +def test_login_json_mode_reports_errors_as_json(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/login", + data={ + "csrf_token": token, "client_id": "beaconmcp_test", + "client_secret": "sk_test", "totp": "000000", + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["ok"] is False + assert "2FA" in res.json()["error"] + + +def _register_passkey(client, token) -> SoftwareAuthenticator: + auth = SoftwareAuthenticator() + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 200, res.text + body = res.json() + res = client.post( + "/app/api/passkeys/register/verify", + json={"state": body["state"], "credential": auth.create(body["options"])}, + headers=_json_headers(token), + ) + assert res.status_code == 201, res.text + return auth + + +def test_register_then_sign_in_with_passkey(web): + client, _, passkey_store = web + token = _login(client) + auth = _register_passkey(client, token) + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + listed = client.get("/app/api/passkeys", headers=_json_headers(token)).json() + assert len(listed["passkeys"]) == 1 + + # Fresh browser: credentials + passkey, no TOTP anywhere. + fresh = TestClient( + client.app, follow_redirects=False, base_url=ORIGIN, + headers={"x-forwarded-proto": "https"}, + ) + token2 = _csrf(fresh) + res = fresh.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + body = res.json() + res = fresh.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "sk_test", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token2), + ) + assert res.status_code == 200, res.text + assert res.json()["ok"] is True + assert res.json()["passkey_count"] == 1 + assert fresh.cookies.get(SESSION_COOKIE) + + +def test_passkey_auth_requires_valid_client_secret(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "wrong"}, + headers=_json_headers(token), + ) + assert res.status_code == 401 + assert res.json()["error"] == "Invalid credentials." + + +def test_passkey_auth_verify_rechecks_the_secret(web): + """The state token alone must never be enough to mint a session.""" + client, _, _ = web + token = _login(client) + auth = _register_passkey(client, token) + body = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ).json() + res = client.post( + "/app/api/passkeys/auth/verify", + json={ + "client_id": "beaconmcp_test", + "client_secret": "wrong", + "state": body["state"], + "credential": auth.get(body["options"]), + }, + headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_endpoints_require_csrf(web): + client, _, _ = web + _csrf(client) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + ) + assert res.status_code == 403 + + +def test_passkey_registration_requires_a_session(web): + client, _, _ = web + token = _csrf(client) + res = client.post( + "/app/api/passkeys/register/options", json={}, headers=_json_headers(token), + ) + assert res.status_code == 401 + + +def test_passkey_delete_scoped_to_session_client(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/api/passkeys/delete", + json={"credential_id": credential_id}, + headers=_json_headers(token), + ) + assert res.status_code == 200 + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_tokens_page_lists_and_removes_passkeys(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + + page = client.get("/app/tokens").text + assert "Passkeys" in page + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + assert credential_id in page + assert "never used" in page + + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": token, "credential_id": credential_id}, + ) + assert res.status_code == 303 + assert res.headers["location"] == "/app/tokens" + assert passkey_store.count_for_client("beaconmcp_test") == 0 + + +def test_passkeys_remove_requires_csrf(web): + client, _, passkey_store = web + token = _login(client) + _register_passkey(client, token) + credential_id = passkey_store.list_for_client("beaconmcp_test")[0].credential_id + res = client.post( + "/app/passkeys/remove", + data={"csrf_token": "wrong", "credential_id": credential_id}, + ) + assert res.status_code == 403 + assert passkey_store.count_for_client("beaconmcp_test") == 1 + + +def test_routes_report_unavailable_without_a_service(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d.db")) + db = Database(tmp_path / "d.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), + follow_redirects=False, base_url=ORIGIN, + ) + client.get("/app/login") + token = client.cookies.get(CSRF_COOKIE) + res = client.post( + "/app/api/passkeys/auth/options", + json={"client_id": "beaconmcp_test", "client_secret": "sk_test"}, + headers=_json_headers(token), + ) + assert res.status_code == 503 + + +def test_login_page_hides_passkeys_when_unavailable(tmp_path, monkeypatch): + monkeypatch.setenv("BEACONMCP_DASHBOARD_DB", str(tmp_path / "d2.db")) + db = Database(tmp_path / "d2.db") + deps = DashboardDeps( + database=db, + session_store=SessionStore(db, key=os.urandom(32)), + client_store=FakeClientStore(), + token_store=FakeTokenStore(), + totp_locked=lambda cid: False, + totp_record_failure=lambda cid: None, + totp_record_success=lambda cid: None, + passkeys=None, + ) + client = TestClient( + Starlette(routes=build_dashboard_routes(deps)), base_url=ORIGIN, + ) + body = client.get("/app/login").text + assert 'data-passkeys-enabled="false"' in body + assert "passkey-login-btn" not in body From 0c24b2230d7c174a495d9a4ef0322eab070ca7cf Mon Sep 17 00:00:00 2001 From: Lony <66854264+Showdown76py@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:44:34 +0200 Subject: [PATCH 5/8] feat(updates): update notice for signed-in operators + self-update MCP tools (#37) * feat(updates): tell signed-in operators about updates, and offer to apply them BeaconMCP cuts no releases and ships no PyPI package: the canonical install is a git clone with a venv and a systemd unit. So "is there an update?" means "is this checkout behind the upstream default branch?", and nothing in the server was answering that question. Operators found out by happening to read the repo. Adds three things. **A notice, for signed-in operators only.** A card on any /app/* page when the checkout is behind: how far, the recent commit subjects, a link to the diff, and the commands to update. GET /app/api/update requires a live session and 401s otherwise -- the exact revision a server runs is free reconnaissance for anyone who hasn't authenticated, and the card is only ever rendered to someone signed in. Dismissing it hides that revision until a newer one lands. **Instructions that match the install**, rather than assuming everyone ran deploy/install.sh. A git checkout gets its own root and its real venv pip path, plus a systemctl line only when a unit file actually exists; a container gets docker compose; a pip distribution gets the git+https URL. **Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update applies: pull --ff-only, reinstall dependencies, validate the config, then restart. It requires confirm=True, refuses a dirty checkout so local edits are never discarded, and refuses a non-git install. The config validation is a hard gate, not a warning, and it is what makes this safe to run unattended: it shells out to `beaconmcp validate-config` so the *new* code parses the operator's *actual* config. If a setting was renamed or a new one is now required, the checkout is reset to where it started, dependencies are restored, and nothing is restarted -- an update that bricks the server is worse than no update. The check also diffs the incoming .env.example / beaconmcp.yaml.example against the operator's real files (not the local examples, and honouring variables already exported), so the notice can say "this update wants a variable you haven't set" *before* it is applied. The dashboard's "Update now" re-prompts for 2FA: pulling code and restarting is the most privileged thing the panel can do, so a session alone is not the right bar -- same gate as minting a token. Both are switchable: features.updates.enabled is the air-gap switch (no egress, no tools, no notice) and allow_self_update keeps the notice while forbidding the apply, for deployments where updates go through a pipeline. Also fixes __version__, which had been pinned at "0.1.0" while pyproject said 2.0.0 -- it now reads package metadata, with the real number as the source-tree fallback. Tests drive git for real against throwaway repositories: a mocked subprocess would only prove the mock agrees with itself. pip and the validation subprocess are the two steps stubbed, so the pull/validate/ roll-back orchestration is exercised without touching the interpreter running the suite. * fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets Two gaps found by actually looking at the rendered pages. **The "You're signed in" screen said nothing.** The toast fetches its status once at page load, which on /app/login happens before the session exists -- so it 401'd and stayed empty, and signing in never re-checks because it does not reload the page. The one moment the operator is guaranteed to pass through said nothing about a pending update. login.js now re-asks once the session is created and renders a one-line mention above "Finish signing in". Deliberately not the full card: that screen has a single primary action, and on a narrow viewport a bottom-anchored card this tall would sit on top of it. The card now opts out of the auth pages entirely and shows on the landing page instead. **Browsers could keep running the previous release's JavaScript.** Starlette serves static files with ETag/Last-Modified but no Cache-Control, which leaves browsers on heuristic freshness -- a file untouched for weeks is reused for a long time without ever revalidating. That was survivable when upgrading meant running commands by hand; it is not once the server can update itself and the next page load is expected to match the new backend. This was not theoretical: it bit the browser used to verify the change, which kept executing a stale bundle across several restarts. Asset URLs now carry a fingerprint of the bundle, recomputed at start from the newest mtime in the static directory (which a git pull bumps). New bytes mean a new URL, so no cache can serve it from an old entry -- which also lets the files be cached hard instead of revalidated: ?v= present -> public, max-age=31536000, immutable ?v= absent -> no-cache (a legacy or hand-typed URL can't pin old code) /app/* pages -> no-store (per-session, and they carry the fingerprint) * fix(updates): serialize update work, and keep the restart off the shell Self-review findings on the update flow. **Two updates could run at once.** The dashboard button and the MCP tool reach `apply_update` independently, so nothing stopped a second one starting mid-pull: two `git pull` / `pip install -e .` runs in one checkout fight over index.lock and can leave a half-applied tree, and one caller's rollback could discard the other's successful update. A second caller is now told an update is already running rather than queued behind a pip that may take minutes -- it never touches git. **Cold-cache checks stampeded.** Every dashboard tab opening at once fired its own `git fetch`, piling up 60 s subprocesses for one answer. The uncached path is now single-flighted; waiters get the result the winner cached. **The deferred restart built a shell string.** `service` is the literal "beaconmcp" today, so this was not exploitable, but interpolating it into `sh -c` means a future change that made the unit name configurable would silently become a shell injection. Values now go through argv. All three are covered by tests, and both locks were mutation-checked: removing either makes its test fail (4 concurrent checks instead of 1; "release unlocked lock" when the second updater proceeds). --- README.md | 3 +- beaconmcp.yaml.example | 14 + docs/configuration.md | 9 + docs/tools.md | 18 +- docs/updates.md | 121 +++ src/beaconmcp/__init__.py | 12 +- src/beaconmcp/__main__.py | 10 +- src/beaconmcp/config.py | 34 +- src/beaconmcp/dashboard/app.py | 154 ++- src/beaconmcp/dashboard/static/app.css | 215 +++++ src/beaconmcp/dashboard/static/login.js | 20 + .../dashboard/static/update_banner.js | 237 +++++ src/beaconmcp/dashboard/templates/base.html | 11 +- src/beaconmcp/dashboard/templates/chat.html | 2 +- .../dashboard/templates/connectors.html | 2 +- src/beaconmcp/dashboard/templates/login.html | 7 +- src/beaconmcp/dashboard/templates/tokens.html | 2 +- .../dashboard/templates/totp_refresh.html | 2 +- src/beaconmcp/maintenance/__init__.py | 5 + src/beaconmcp/maintenance/tools.py | 117 +++ src/beaconmcp/server.py | 6 + src/beaconmcp/updates.py | 821 ++++++++++++++++ tests/test_updates.py | 907 ++++++++++++++++++ 23 files changed, 2713 insertions(+), 16 deletions(-) create mode 100644 docs/updates.md create mode 100644 src/beaconmcp/dashboard/static/update_banner.js create mode 100644 src/beaconmcp/maintenance/__init__.py create mode 100644 src/beaconmcp/maintenance/tools.py create mode 100644 src/beaconmcp/updates.py create mode 100644 tests/test_updates.py diff --git a/README.md b/README.md index f002531..638c01b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ install. Both are covered in [Installation](docs/installation.md). |-------|--------------| | [Installation](docs/installation.md) | Requirements, Docker, systemd install, config wizard, reverse proxy, updates | | [Configuration](docs/configuration.md) | The two config files, every YAML key that matters, where to run the server | -| [Tools](docs/tools.md) | The 44 MCP tools, grouped by module | +| [Tools](docs/tools.md) | The 46 MCP tools, grouped by module | +| [Updates](docs/updates.md) | The update notice, the self-update tools, and how to turn both off | | [Client setup](docs/clients.md) | Assistant, ChatGPT, Gemini, Mistral, VS Code, Cursor, OpenCode | | [Security](docs/security.md) | What to review before approving a tool call, token handling, TOTP hygiene | | [Dashboard](docs/dashboard.md) | The optional `/app/*` web panel: login, API tokens, Gemini chat | diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index db590ed..3ceda3b 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -230,6 +230,20 @@ features: public_url: https://mcp.example.com # used to generate MCP URLs in the UI mcp_mode: local # "local" (default) or "remote" + # Update notifications. When enabled, the server periodically compares + # this checkout against the upstream default branch and shows a notice + # in the dashboard (signed-in operators only), with instructions matched + # to how BeaconMCP was installed here. Also exposes the + # beaconmcp_check_update / beaconmcp_self_update MCP tools. + updates: + # Set to false on an air-gapped or change-controlled deployment: the + # server then never contacts the git remote at all. + enabled: true + # Set to false to keep the notification but forbid applying it from + # the dashboard or over MCP -- appropriate when updates go through a + # deployment pipeline. Manual instructions are still shown. + allow_self_update: true + # Free-form infrastructure context exposed as an MCP resource. Edit freely: # the LLM reads this to understand your topology, naming conventions, and # operational notes. diff --git a/docs/configuration.md b/docs/configuration.md index ab2e1b3..9f1b14b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,3 +70,12 @@ Tailscale IP, a VPN address, a bastion. Everything else about the panel — enabling it, the tokens page, cost tracking, the confirmation modal — is in [dashboard.md](dashboard.md). + +## Updates + +| Key | Notes | +|-----|-------| +| `features.updates.enabled` | Default `true`. Compares this checkout against the upstream default branch and shows a notice to signed-in operators. Set to `false` on an air-gapped or change-controlled box: the server then never contacts the git remote, and the `beaconmcp_*_update` MCP tools are not registered. | +| `features.updates.allow_self_update` | Default `true`. Set to `false` to keep the notification but forbid applying it from the dashboard or over MCP — the right setting when deploys go through a pipeline. Manual instructions are still shown. | + +See [updates.md](updates.md) for the notice, the MCP tools, and what the self-update does. diff --git a/docs/tools.md b/docs/tools.md index 87a3bd5..9eb368d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,8 +1,9 @@ # MCP tools -44 tools across six modules. The infrastructure modules are only registered when the matching +46 tools across seven modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from -Proxmox or BMC. `security_end_session` is always registered, whatever the topology. +Proxmox or BMC. `security_end_session` and the two maintenance tools are always registered, +whatever the topology. Long-running commands (`proxmox_run`, `ssh_run`) are synchronous by default. Pass `wait=False` to start one in the background and get an `exec_id` back, then call the same tool with `exec_id=` to @@ -92,3 +93,16 @@ to that device. | Tool | Description | |------|-------------| | `security_end_session` | Revoke the bearer token used for the current request, ~8 s after responding. Call it as the last step of a task to shrink the window in which a stolen token can be replayed — never mid-task, or the next call gets a 401. | + +## Maintenance (2) + +Registered on every deployment shape, unless `features.updates.enabled` is `false`. + +| Tool | Description | +|------|-------------| +| `beaconmcp_check_update` | Read-only. Reports the running version, how many commits this install is behind upstream, the changelog, any `.env` / `beaconmcp.yaml` settings the new revision knows about that this install has not set, and the exact commands that would update *this* install (git checkout, pip install and container each get different ones). Cached for a few hours. | +| `beaconmcp_self_update` | Applies the update: `git pull --ff-only` → reinstall the package and its dependencies → **validate the config against the new code** → schedule a restart. Requires `confirm=True`. Refuses on a dirty checkout or a non-git install. If the new revision cannot load the current config, everything is rolled back and nothing restarts. Hidden when `features.updates.allow_self_update` is `false`. | + +The restart is deferred a few seconds so the tool result reaches the caller before the process dies. +Show the user `beaconmcp_check_update` output — especially any new configuration — and get an +explicit go-ahead before calling `beaconmcp_self_update`. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..47d213c --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,121 @@ +# Updates + +BeaconMCP publishes no releases and no PyPI package: the canonical install is a `git clone` at +`/opt/beaconmcp` with a venv and a systemd unit. So "is there an update?" means **is this checkout +behind the upstream default branch?** + +The server answers that question itself, tells signed-in operators, and can apply the update. + +## The notice + +Signed in to the dashboard, a card appears bottom-right on any `/app/*` page when the checkout is +behind: + +- how far behind, and the revision range (`9f496cb → abc1234`); +- the last few commit subjects, and a link to the full diff on GitHub; +- **any configuration the new revision knows about that you have not set** — new `.env` variables, + new `beaconmcp.yaml` settings; +- the exact commands to update *this* install; +- an **Update now** button, when an automatic update is possible. + +Dismissing it hides that specific revision; the card returns when a newer one lands. + +On the **"You're signed in"** screen — the moment the session is created, one click before the panel +— you get a one-line mention instead of the full card. That screen has a single primary action, and +on a narrow viewport a bottom-anchored card this tall would sit right on top of it. The card itself +opts out of the auth pages entirely and shows on the landing page. + +The endpoint behind it (`GET /app/api/update`) requires a live session and returns `401` otherwise. +That is deliberate: the exact revision a server runs is free reconnaissance for anyone who has not +authenticated, and the card is only ever rendered to someone signed in. + +## Instructions match your install + +Detection is not a guess about how you *should* have installed it: + +| Detected | What you are told | +|----------|-------------------| +| git checkout | `cd ` → `git pull --ff-only` → `/bin/pip install -e .` (the real venv path, when there is one) → `systemctl restart beaconmcp` if a unit file exists | +| container | `docker compose pull` → `docker compose up -d` | +| pip distribution | `pip install --upgrade 'beaconmcp @ git+https://github.com/Showdown76py/BeaconMCP.git'` | +| unknown | Re-run `deploy/install.sh` from a checkout | + +## MCP tools + +Two tools, registered on every deployment shape: + +- **`beaconmcp_check_update`** — read-only. Version, commits behind, changelog, new configuration, + and the commands for this install. Cached for a few hours. +- **`beaconmcp_self_update`** — applies it. Requires `confirm=True`. + +Ask your assistant to "check whether BeaconMCP has an update" and it will read the changelog and any +new settings back to you before touching anything. + +## What the self-update actually does + +In order, stopping at the first failure: + +1. **Preflight** — refuses on a non-git install, and refuses when the checkout has uncommitted + changes. Local edits are never discarded. +2. **`git pull --ff-only`** — a fast-forward or nothing. No merges, no rebases. +3. **Reinstall** — `pip install -e .` in the detected venv, so new or bumped dependencies land. +4. **Validate the config** — runs `beaconmcp validate-config` in a subprocess, so the *new* code + parses your *actual* configuration. +5. **Restart** — `systemctl restart`, deferred a few seconds so the response reaches you first. + +Step 4 is a hard gate, and it is the reason this is safe to run unattended. If the new revision +cannot load your config — a setting was renamed, a new one is now required — the checkout is reset +to exactly where it started, dependencies are restored, **nothing is restarted**, and the error from +the validator is handed back to you. An update that bricks the server is worse than no update. + +Only one update runs at a time. The dashboard button and the MCP tool reach the same code, and two +`git pull` / `pip install` runs in one checkout would fight over `index.lock`. A second caller is +told one is already in progress rather than queued behind a pip that may take minutes. + +### From the dashboard + +The **Update now** button asks for a fresh 2FA code before it runs. Pulling code and restarting the +process is the most privileged thing the panel can do, so a session alone is not enough — same bar +as minting an API token. + +## Turning it off + +```yaml +features: + updates: + enabled: true # false: never contact the remote, no tools, no notice + allow_self_update: true # false: keep the notice, forbid applying it +``` + +`enabled: false` is the air-gap switch. `allow_self_update: false` is for deployments where updates +go through a pipeline: operators still see that one is available, with instructions, but neither the +dashboard button nor the MCP tool exists. + +## Stale assets after an update + +A server that can update itself must not leave browsers running the previous release's JavaScript. +Starlette serves static files with `ETag`/`Last-Modified` but no `Cache-Control`, which puts +browsers on *heuristic* freshness: a file untouched for weeks is reused for a long time without ever +revalidating. + +Asset URLs therefore carry a fingerprint of the bundle (`app.css?v=6a69fedf`), recomputed at each +start from the newest mtime in the static directory — which a `git pull` bumps. New bytes mean a new +URL, so no cache can satisfy the request from an old entry: + +| Response | `Cache-Control` | +|----------|-----------------| +| Asset with `?v=` | `public, max-age=31536000, immutable` | +| Asset without | `no-cache` (revalidate every time — a legacy or hand-typed URL can never pin stale code) | +| Any `/app/*` page or API reply | `no-store` (per-session, and it carries the fingerprint) | + +## Audit trail + +Every attempt is logged (see [security.md](security.md#audit-trail)): + +| Event | When | +|-------|------| +| `dashboard.update.start` / `dashboard.update.finish` | Update applied from the panel | +| `maintenance.self_update.start` / `maintenance.self_update.finish` | Update applied over MCP | + +The `finish` events carry `ok`, `from_ref`, `to_ref` and `rolled_back`, so a rollback is visible in +the log without reading the tool output. diff --git a/src/beaconmcp/__init__.py b/src/beaconmcp/__init__.py index 3dc1f76..6364084 100644 --- a/src/beaconmcp/__init__.py +++ b/src/beaconmcp/__init__.py @@ -1 +1,11 @@ -__version__ = "0.1.0" +"""BeaconMCP -- remote MCP server for Proxmox VE and BMC infrastructure.""" + +try: # pragma: no cover - trivial + from importlib.metadata import version as _version + + __version__ = _version("beaconmcp") +except Exception: # noqa: BLE001 - running straight from an uninstalled tree + # Fallback when the package was never pip-installed. Keep in sync with + # [project].version in pyproject.toml. (The old hard-coded "0.1.0" had + # drifted three majors behind it.) + __version__ = "2.0.0" diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 443f366..f711726 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -2180,6 +2180,8 @@ async def lifespan(_app): login_limiter=_login_limiter, trusted_proxies=tuple(config.server.trusted_proxies), passkey_service=passkey_service, + updates=config.features.updates, + config_path=config.source_path, ) app = Starlette( @@ -2246,7 +2248,8 @@ def _build_dashboard_routes(client_store, token_store, totp_locked, totp_record_failure, totp_record_success, *, dyn_reg=None, shared_database=None, login_limiter=None, trusted_proxies=(), - passkey_service=None): + passkey_service=None, updates=None, + config_path=None): """Build dashboard routes if enabled. Returns [] when disabled.""" from . import dashboard if not dashboard.is_enabled(): @@ -2320,6 +2323,11 @@ def _float_env(name: str, default: float) -> float: login_limiter=login_limiter, trusted_proxies=trusted_proxies, passkeys=passkey_service, + updates_enabled=updates.enabled if updates is not None else True, + allow_self_update=( + updates.allow_self_update if updates is not None else True + ), + config_path=config_path, ) return build_dashboard_routes(deps) diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index 14f1272..c7f89fd 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -168,10 +168,26 @@ class DashboardConfig: mcp_mode: str = "local" # "local" | "remote" +@dataclass +class UpdatesConfig: + """Update checking and self-update. + + ``enabled`` is the network-egress switch: turning it off means the + server never contacts the git remote, which is what an air-gapped or + change-controlled deployment wants. ``allow_self_update`` keeps the + check but removes the ability to apply one from the dashboard or over + MCP -- appropriate when updates go through a deployment pipeline. + """ + + enabled: bool = True + allow_self_update: bool = True + + @dataclass class FeaturesConfig: dashboard: DashboardConfig = field(default_factory=DashboardConfig) ssh_enabled: bool = True + updates: UpdatesConfig = field(default_factory=UpdatesConfig) @dataclass @@ -183,6 +199,10 @@ class Config: features: FeaturesConfig verify_ssl: bool infrastructure: dict + #: YAML file this config was loaded from, or ``None`` on the legacy + #: env-var path. The self-update flow needs it to re-validate the + #: operator's *actual* config against newly pulled code. + source_path: Path | None = None # --- Loading ---------------------------------------------------------- @@ -255,7 +275,7 @@ def _from_yaml(cls, path: Path) -> Config: if not isinstance(raw, dict): raise ConfigError(f"{path}: top-level YAML must be a mapping.") resolved = _resolve_env_refs(raw, path=path) - return cls._build(resolved) + return cls._build(resolved, source_path=path) @classmethod def _from_legacy_env(cls) -> Config: @@ -328,7 +348,7 @@ def _from_legacy_env(cls) -> Config: return cls._build(raw) @classmethod - def _build(cls, raw: dict) -> Config: + def _build(cls, raw: dict, *, source_path: Path | None = None) -> Config: proxmox_raw = raw.get("proxmox") or {} nodes_raw = proxmox_raw.get("nodes") or [] @@ -554,9 +574,14 @@ def _build(cls, raw: dict) -> Config: public_url=dash_raw.get("public_url"), mcp_mode=(dash_raw.get("mcp_mode") or "local").strip().lower(), ) + updates_raw = feat_raw.get("updates") or {} features = FeaturesConfig( dashboard=dashboard, ssh_enabled=_bool((feat_raw.get("ssh") or {}).get("enabled", True)), + updates=UpdatesConfig( + enabled=_bool(updates_raw.get("enabled", True)), + allow_self_update=_bool(updates_raw.get("allow_self_update", True)), + ), ) # Cross-capability validation ---------------------------------------- @@ -597,6 +622,7 @@ def _build(cls, raw: dict) -> Config: features=features, verify_ssl=_bool(proxmox_raw.get("verify_ssl", False)), infrastructure=raw.get("infrastructure") or {}, + source_path=source_path, ) # --- Accessors -------------------------------------------------------- @@ -748,6 +774,10 @@ def mask(value: str) -> str: "mcp_mode": self.features.dashboard.mcp_mode, }, "ssh_enabled": self.features.ssh_enabled, + "updates": { + "enabled": self.features.updates.enabled, + "allow_self_update": self.features.updates.allow_self_update, + }, }, "infrastructure": self.infrastructure, } diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index 34b8f54..ab7e34a 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -103,6 +103,13 @@ class DashboardDeps: # unset (or reporting ``available is False``), the login page hides # every passkey affordance and the TOTP path is the only way in. passkeys: PasskeyService | None = None + # Update notifications. ``updates_enabled`` gates the check (and with + # it any network egress); ``allow_self_update`` gates the "Update now" + # button. ``config_path`` is the YAML the update flow re-validates + # against freshly pulled code. + updates_enabled: bool = True + allow_self_update: bool = True + config_path: Path | None = None # --------------------------------------------------------------------------- @@ -170,6 +177,7 @@ def _render( if not token: token = csrf.issue_token() context["csrf_token"] = token + context["asset_v"] = ASSET_VERSION response = _TEMPLATES.TemplateResponse( request, template, context, status_code=status_code ) @@ -191,6 +199,11 @@ def _render( def _apply_security_headers(response: Response) -> None: response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") + # Panel pages and API replies are per-session and must not be reused -- + # by a shared cache, or by the back button after a sign-out. It also + # keeps the asset fingerprints these pages embed from going stale. + # setdefault, so the SSE stream keeps its own directives. + response.headers.setdefault("Cache-Control", "no-store") response.headers.setdefault( "Referrer-Policy", "strict-origin-when-cross-origin" ) @@ -257,6 +270,60 @@ def _totp_error(result: TotpResult, invalid_message: str) -> str: return TOTP_REPLAY_MESSAGE if result is TotpResult.REPLAY else invalid_message +def _compute_asset_version() -> str: + """Fingerprint the static bundle, for cache-busting query strings. + + Starlette serves static files with ``ETag``/``Last-Modified`` but no + ``Cache-Control``, which leaves browsers on *heuristic* freshness: an + asset untouched for weeks is reused for a long time without ever + revalidating. That was survivable when upgrading meant an operator + running commands by hand; now that the server can update itself, the + next page load would happily keep executing the previous release's + JavaScript against a new backend. + + Stamping the URLs with a fingerprint fixes it deterministically -- new + bytes mean a new URL, which no cache can satisfy from an old entry -- + and lets the files themselves be cached hard (see + :class:`_ImmutableStaticFiles`). Newest mtime in the directory is + enough: a ``git pull`` rewrites the files it changes. + """ + try: + newest = max( + p.stat().st_mtime + for p in (_DASHBOARD_DIR / "static").iterdir() + if p.is_file() + ) + except (OSError, ValueError): + return "0" + return format(int(newest), "x") + + +#: Computed once per process: a restart is exactly when the bundle can change. +ASSET_VERSION = _compute_asset_version() + + +class _ImmutableStaticFiles(StaticFiles): + """StaticFiles for URLs that carry a content fingerprint. + + Safe to cache hard *because* the query string changes whenever the + bytes do. Requests without a version (a hand-typed URL, an old cached + page) fall back to revalidate-every-time so they can never pin stale + code. + """ + + def file_response(self, full_path, stat_result, scope, *args, **kwargs) -> Response: + response = super().file_response( + full_path, stat_result, scope, *args, **kwargs + ) + query = scope.get("query_string") or b"" + versioned = b"v=" in query + response.headers.setdefault( + "Cache-Control", + "public, max-age=31536000, immutable" if versioned else "no-cache", + ) + return response + + def _wants_json(request: Request) -> bool: """True when the caller is the login page's fetch() rather than a form POST. @@ -1096,6 +1163,89 @@ async def api_passkeys_auth_verify(request: Request) -> Response: ) return response + # --- Update notifications -------------------------------------------- + + async def api_update_status(request: Request) -> Response: + """Update status for the signed-in operator. + + Deliberately session-gated: an anonymous visitor learning the exact + revision a server runs is free reconnaissance, and the banner is + only ever rendered to someone already signed in. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not deps.updates_enabled: + return _json({"enabled": False, "available": False}) + + from .. import updates as updates_mod + + force = request.query_params.get("force") == "1" + # The check shells out to git (network); keep it off the event loop. + info = await asyncio.to_thread( + updates_mod.check_for_update, force=force, config_path=deps.config_path, + ) + payload = info.to_json() + payload["enabled"] = True + payload["self_update_allowed"] = deps.allow_self_update + if not deps.allow_self_update: + payload["can_self_update"] = False + return _json(payload) + + async def api_update_apply(request: Request) -> Response: + """Apply an update from the dashboard, behind a fresh 2FA code. + + Pulling code and restarting the process is the most privileged + thing this panel can do, so it is gated exactly like minting a + token: a session is not enough, the operator re-proves the second + factor at the moment of the action. + """ + session = _require_active_session(request, deps) + if isinstance(session, Response): + return session + if not await csrf.verify(request): + return _json({"error": "csrf"}, status=403) + if not (deps.updates_enabled and deps.allow_self_update): + return _json( + { + "ok": False, + "error": "Self-update is disabled on this server " + "(features.updates.allow_self_update).", + }, + status=403, + ) + + body = await _read_json(request) + totp = str(body.get("totp") or "").strip() + if not totp: + return _json({"ok": False, "error": "2FA code is required."}, status=400) + if deps.totp_locked(session.client_id): + return _json( + {"ok": False, "error": "Too many 2FA attempts; try again in 5 minutes."}, + status=429, + ) + totp_result = _check_totp(deps, session.client_id, totp) + if totp_result is not TotpResult.OK: + return _json( + {"ok": False, "error": _totp_error(totp_result, "Invalid 2FA code.")}, + status=401, + ) + deps.totp_record_success(session.client_id) + + from .. import updates as updates_mod + + audit.emit("dashboard.update.start", client_id=session.client_id) + result = await asyncio.to_thread( + updates_mod.apply_update, config_path=deps.config_path, + ) + audit.emit( + "dashboard.update.finish", client_id=session.client_id, + ok=result.ok, from_ref=result.from_ref, to_ref=result.to_ref, + rolled_back=result.rolled_back, + ) + updates_mod.invalidate_cache() + return _json(result.to_json(), status=200 if result.ok else 500) + async def chat_get(request: Request) -> Response: session = _load_session(request, deps) if not session: @@ -1467,6 +1617,8 @@ async def _confirm(req: ToolConfirmRequired) -> bool: ), Route("/app/api/passkeys/delete", api_passkeys_delete, methods=["POST"]), Route("/app/passkeys/remove", passkeys_remove, methods=["POST"]), + Route("/app/api/update", api_update_status, methods=["GET"]), + Route("/app/api/update/apply", api_update_apply, methods=["POST"]), Route( "/app/api/passkeys/auth/options", api_passkeys_auth_options, methods=["POST"], @@ -1478,7 +1630,7 @@ async def _confirm(req: ToolConfirmRequired) -> bool: Route("/", index, methods=["GET"]), Mount( "/app/static", - app=StaticFiles(directory=_DASHBOARD_DIR / "static"), + app=_ImmutableStaticFiles(directory=_DASHBOARD_DIR / "static"), name="dashboard-static", ), ] diff --git a/src/beaconmcp/dashboard/static/app.css b/src/beaconmcp/dashboard/static/app.css index 7488464..b53ece4 100644 --- a/src/beaconmcp/dashboard/static/app.css +++ b/src/beaconmcp/dashboard/static/app.css @@ -2315,3 +2315,218 @@ a.cta:hover { background: var(--accent-hover); } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .passkey-row .passkey-sub { font-size: 12px; color: var(--fg-muted); } + +/* =============================================================== + UPDATE TOAST (every /app page, signed-in only) + =============================================================== */ + +.update-toast { + position: fixed; + right: 18px; bottom: 18px; + z-index: 60; + width: min(380px, calc(100vw - 36px)); + max-height: min(72vh, 640px); + overflow-y: auto; + padding: 16px 18px; + background: var(--bg-elev); + border: 1px solid var(--accent-border); + border-radius: 14px; + box-shadow: var(--shadow-lg, 0 8px 32px rgba(20,14,8,0.16)); + font-size: 13px; + animation: ut-rise 320ms var(--ease-out) both; +} +@keyframes ut-rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.update-toast .ut-head { + display: flex; align-items: center; gap: 8px; + font-size: 14px; +} +.update-toast .ut-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-x { + margin-left: auto; + background: transparent; border: 0; padding: 2px; + color: var(--fg-faint); cursor: pointer; line-height: 0; + border-radius: 6px; +} +.update-toast .ut-x:hover { color: var(--fg); background: var(--bg-soft); } + +.update-toast .ut-sub { + margin: 6px 0 12px; + color: var(--fg-muted); +} +.update-toast code { + font-family: var(--font-mono); + font-size: 11.5px; + background: var(--bg-soft); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 1px 4px; +} + +.update-toast .ut-log { + list-style: none; + margin: 0 0 12px; padding: 0; + display: grid; gap: 4px; +} +.update-toast .ut-log li { + color: var(--fg-mid); + font-size: 12.5px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.update-toast .ut-log .ut-more { color: var(--fg-faint); } + +.update-toast .ut-warn { + margin: 0 0 12px; + padding: 9px 11px; + border-radius: 9px; + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 30%, var(--border)); + color: var(--fg); + display: grid; gap: 4px; + font-size: 12.5px; +} +.update-toast .ut-warn strong { color: var(--danger); } + +.update-toast .ut-steps-head { + display: flex; align-items: center; gap: 8px; + font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--fg-faint); + margin-bottom: 6px; +} +.update-toast .ut-copy { + margin-left: auto; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 2px 8px; + font: 500 11px var(--font); + color: var(--fg-mid); cursor: pointer; + text-transform: none; letter-spacing: 0; +} +.update-toast .ut-copy:hover { color: var(--fg); border-color: var(--accent-border); } + +.update-toast .ut-steps { + margin: 0 0 12px; + padding: 10px 12px; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 9px; + overflow-x: auto; +} +.update-toast .ut-steps code { + background: transparent; border: 0; padding: 0; + white-space: pre; + font-size: 11.5px; + color: var(--fg-mid); +} + +.update-toast .ut-block { + margin: 0 0 12px; + font-size: 12px; + color: var(--fg-muted); +} + +.update-toast .ut-actions { + display: flex; align-items: center; gap: 10px; +} +.update-toast .ut-primary { + padding: 8px 14px; + border: 0; border-radius: 8px; + background: var(--accent); color: var(--accent-fg); + font: 600 13px var(--font); + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + position: relative; overflow: hidden; +} +.update-toast .ut-primary:hover:not(:disabled) { background: var(--accent-hover); } +.update-toast .ut-primary:disabled { opacity: 0.75; cursor: progress; } +.update-toast .ut-primary.is-loading::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.38) 50%, transparent 80%); + transform: translateX(-100%); + animation: shimmer-sweep 1150ms var(--ease-out) infinite; +} +.update-toast .ut-link { + color: var(--fg-mid); font-size: 12.5px; text-decoration: none; + border-bottom: 1px solid var(--border-strong); +} +.update-toast .ut-link:hover { color: var(--accent); border-color: var(--accent-border); } + +.update-toast .ut-result { margin-top: 12px; } +.update-toast .ut-label { + display: block; + font-size: 12px; color: var(--fg-mid); margin-bottom: 6px; +} +.update-toast .ut-totp-row { display: flex; gap: 8px; } +.update-toast .ut-totp-row input { + flex: 1; min-width: 0; + padding: 8px 10px; + background: var(--bg-soft); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--fg); + font-family: var(--font-mono); + letter-spacing: 0.18em; + outline: none; +} +.update-toast .ut-totp-row input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.update-toast .ut-note { + margin: 8px 0 0; + font-size: 11.5px; line-height: 1.5; color: var(--fg-muted); +} +.update-toast .ut-ok, +.update-toast .ut-err { + padding: 9px 11px; + border-radius: 9px; + font-size: 12.5px; + line-height: 1.5; +} +.update-toast .ut-ok { + background: var(--success-soft); + border: 1px solid color-mix(in oklab, var(--success) 32%, var(--border)); +} +.update-toast .ut-err { + background: var(--danger-soft); + border: 1px solid color-mix(in oklab, var(--danger) 32%, var(--border)); +} + +@media (max-width: 560px) { + .update-toast { right: 10px; left: 10px; bottom: 10px; width: auto; } +} +@media (prefers-reduced-motion: reduce) { + .update-toast { animation: none; } + .update-toast .ut-primary.is-loading::after { animation: none; opacity: 0.25; } +} + +/* One-line update mention on the post-2FA screen. The full toast opts out + of the auth pages (see update_banner.js) so it can't sit on top of + "Finish signing in" on a narrow viewport. */ +.auth-card .update-note { + display: block; + margin-top: 18px; + padding: 9px 12px; + border: 1px solid var(--accent-border); + border-radius: 9px; + background: var(--accent-softer); + color: var(--fg-mid); + font-size: 12.5px; + line-height: 1.45; + text-decoration: none; + transition: background 160ms var(--ease-out), color 160ms var(--ease-out); +} +.auth-card .update-note:hover { + background: var(--accent-soft); + color: var(--fg); +} diff --git a/src/beaconmcp/dashboard/static/login.js b/src/beaconmcp/dashboard/static/login.js index 5292230..5f8f00b 100644 --- a/src/beaconmcp/dashboard/static/login.js +++ b/src/beaconmcp/dashboard/static/login.js @@ -263,6 +263,26 @@ } } if (finishBtn) finishBtn.focus(); + mentionUpdate(); + } + + // The toast in base.html fetched its status before this session existed, + // so it came back 401 and stayed empty. Now that we're signed in, ask + // again -- a pending update is worth knowing about here, one click before + // entering the panel. Kept to a single line: this screen already has a + // primary action and the full card (with commands) waits on the landing + // page. + function mentionUpdate() { + var note = document.getElementById("update-note"); + if (!note || !window.BeaconUpdates) return; + window.BeaconUpdates.check().then(function(data) { + if (!data) return; + var behind = data.behind === 1 + ? "1 commit behind" : data.behind + " commits behind"; + note.textContent = "An update is available — " + behind + + " " + (data.branch || "main") + ". Details after signing in."; + note.hidden = false; + }); } function finish() { diff --git a/src/beaconmcp/dashboard/static/update_banner.js b/src/beaconmcp/dashboard/static/update_banner.js new file mode 100644 index 0000000..3008442 --- /dev/null +++ b/src/beaconmcp/dashboard/static/update_banner.js @@ -0,0 +1,237 @@ +// "An update is available" toast, shown on every /app page to a signed-in +// operator. The endpoint is session-authenticated, so a 401 (login page, +// signed out) simply leaves the toast hidden -- no branching needed here. +(function() { + "use strict"; + + var root = document.getElementById("update-toast"); + if (!root) return; + + var DISMISS_KEY = "beaconmcp-update-dismissed"; + var state = null; + + function csrfToken() { + var m = document.cookie.match(/(?:^|;\s*)beaconmcp_csrf_token=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ""; + } + + function dismissed(ref) { + try { + return window.localStorage.getItem(DISMISS_KEY) === ref; + } catch (e) { + return false; + } + } + + function remember(ref) { + try { + window.localStorage.setItem(DISMISS_KEY, ref); + } catch (e) {} + } + + function esc(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); + } + + function plural(n, one, many) { + return n + " " + (n === 1 ? one : many); + } + + function render(data) { + state = data; + var commits = data.commits || []; + var cfg = data.config || {}; + var newEnv = cfg.new_env_vars || []; + var newKeys = cfg.new_config_keys || []; + + var html = '' + + '
' + + '' + + 'Update available' + + '' + + '
' + + '

' + + esc(plural(data.behind, "commit", "commits")) + " behind " + + '' + esc(data.branch || "main") + '' + + (data.current_ref && data.latest_ref + ? ' · ' + esc(data.current_ref) + ' → ' + esc(data.latest_ref) + '' + : "") + + '

'; + + if (commits.length) { + html += '
    '; + commits.slice(0, 4).forEach(function(c) { + html += '
  • ' + esc(c.sha) + ' ' + esc(c.subject) + '
  • '; + }); + if (commits.length > 4) { + html += '
  • + ' + + esc(plural(commits.length - 4, "more commit", "more commits")) + '
  • '; + } + html += '
'; + } + + if (newEnv.length || newKeys.length) { + html += '
Needs configuration'; + if (newEnv.length) { + html += '
New .env variables: ' + + newEnv.map(function(v) { return '' + esc(v) + ''; }).join(", ") + + '
'; + } + if (newKeys.length) { + html += '
New beaconmcp.yaml settings: ' + + newKeys.slice(0, 6).map(function(v) { return '' + esc(v) + ''; }).join(", ") + + (newKeys.length > 6 ? ", …" : "") + + '
'; + } + html += '
'; + } + + var steps = (data.instructions || []).join("\n"); + html += '
' + + 'To update this ' + esc(data.install_kind) + ' install' + + '' + + '
' + + '
' + esc(steps) + '
'; + + if (data.blockers && data.blockers.length) { + html += '

Automatic update unavailable: ' + + esc(data.blockers.join("; ")) + '

'; + } + + html += '
'; + if (data.can_self_update && data.self_update_allowed) { + html += ''; + } + if (data.compare_url) { + html += 'View changes'; + } + html += '
'; + html += ''; + + root.innerHTML = html; + root.hidden = false; + + var close = document.getElementById("ut-close"); + if (close) { + close.addEventListener("click", function() { + remember(data.latest_ref); + root.hidden = true; + }); + } + var copy = document.getElementById("ut-copy"); + if (copy) { + copy.addEventListener("click", function() { + navigator.clipboard.writeText(steps).then(function() { + copy.textContent = "Copied"; + setTimeout(function() { copy.textContent = "Copy"; }, 1600); + }, function() {}); + }); + } + var go = document.getElementById("ut-go"); + if (go) go.addEventListener("click", askForCode); + } + + // Applying an update pulls code and restarts the process -- gated on a + // fresh 2FA code, the same bar as minting a token. + function askForCode() { + var box = document.getElementById("ut-result"); + if (!box) return; + box.hidden = false; + box.innerHTML = '' + + '' + + '
' + + '' + + '' + + '
' + + '

Pulls the new code, reinstalls dependencies, ' + + 're-validates your config, then restarts. If the new code can\'t load ' + + 'your config it rolls back and does not restart.

'; + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + if (input) input.focus(); + if (input) { + input.addEventListener("keydown", function(e) { + if (e.key === "Enter") { e.preventDefault(); run(); } + }); + } + if (confirm) confirm.addEventListener("click", run); + } + + function run() { + var input = document.getElementById("ut-totp"); + var confirm = document.getElementById("ut-confirm"); + var box = document.getElementById("ut-result"); + var code = input ? (input.value || "").replace(/\D/g, "") : ""; + if (code.length !== 6) { + if (input) input.focus(); + return; + } + if (confirm) { + confirm.classList.add("is-loading"); + confirm.disabled = true; + var label = confirm.querySelector(".btn-label"); + if (label) label.textContent = "Updating…"; + } + fetch("/app/api/update/apply", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify({ totp: code }), + }).then(function(res) { + return res.json().catch(function() { return {}; }); + }).then(function(data) { + if (!box) return; + if (data.ok) { + var tail = data.restart_scheduled + ? " The server restarts in " + data.restart_in_seconds + + "s — this page will be briefly unreachable." + : ""; + box.innerHTML = '
Updated. ' + + esc(data.message || "") + esc(tail) + '
'; + remember(state && state.latest_ref); + } else { + box.innerHTML = '
Update failed. ' + + esc(data.message || data.error || "Unknown error.") + '
'; + } + }).catch(function() { + if (box) { + box.innerHTML = '
Network error while updating.
'; + } + }); + } + + // Resolves to the payload when an undismissed update exists, else null. + function check() { + return fetch("/app/api/update", { credentials: "same-origin" }) + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || !data.enabled || !data.available) return null; + return data; + }) + .catch(function() { return null; }); + } + + // Exposed so the login page can mention an update the moment the session + // exists -- its own fetch below already ran (and 401'd) before sign-in. + window.BeaconUpdates = { check: check, dismissed: dismissed }; + + // The auth pages opt out of the toast itself: they are single-purpose + // screens, and on a narrow viewport a bottom-anchored card this tall + // would sit right on top of their primary button. login.js renders a + // one-line mention instead, and the full card shows on the landing page. + if (document.body.classList.contains("auth-page")) return; + + check().then(function(data) { + if (data && !dismissed(data.latest_ref)) render(data); + }); +})(); diff --git a/src/beaconmcp/dashboard/templates/base.html b/src/beaconmcp/dashboard/templates/base.html index 3e817c0..3620fb4 100644 --- a/src/beaconmcp/dashboard/templates/base.html +++ b/src/beaconmcp/dashboard/templates/base.html @@ -8,11 +8,18 @@ - - + + {% block head %}{% endblock %} {% block body %}{% endblock %} + + {# Update notice. Populated by a session-authenticated fetch, so it stays + invisible on the login page and to anyone signed out. Anchored to the + viewport rather than the flow: /app/chat is a full-height grid and a + banner in the document flow would push its layout around. #} + + diff --git a/src/beaconmcp/dashboard/templates/chat.html b/src/beaconmcp/dashboard/templates/chat.html index a984238..4b0a847 100644 --- a/src/beaconmcp/dashboard/templates/chat.html +++ b/src/beaconmcp/dashboard/templates/chat.html @@ -192,5 +192,5 @@

Rolling 7-day window

- + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/connectors.html b/src/beaconmcp/dashboard/templates/connectors.html index 20f3ee9..c8811fe 100644 --- a/src/beaconmcp/dashboard/templates/connectors.html +++ b/src/beaconmcp/dashboard/templates/connectors.html @@ -132,5 +132,5 @@

OAuth connectors

- + {% endblock %} diff --git a/src/beaconmcp/dashboard/templates/login.html b/src/beaconmcp/dashboard/templates/login.html index 41684f2..a784041 100644 --- a/src/beaconmcp/dashboard/templates/login.html +++ b/src/beaconmcp/dashboard/templates/login.html @@ -145,6 +145,9 @@

You're signed in

localhost address).

+ {# Filled in by login.js once the session exists -- see update_banner.js #} + +