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 += ''; + } + + 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 #} + +