From 8a446bb4b57da9d49364cfbb6c213f416b886c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 18:12:57 +0000 Subject: [PATCH] offscale, extracted from the platform into its own repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + PLUGIN.md | 733 ++++++++++++++++++++++++++++++++++ api/router.ts | 18 + db/queries.ts | 186 +++++++++ db/schema.ts | 55 +++ manifest.ts | 47 +++ sidecar/active.ts | 20 + sidecar/assist.ts | 153 +++++++ sidecar/claude-proxy.ts | 97 +++++ sidecar/client.ts | 113 ++++++ sidecar/companion.ts | 204 ++++++++++ sidecar/enroll.ts | 103 +++++ sidecar/index.ts | 149 +++++++ sidecar/invites.ts | 183 +++++++++ sidecar/keys.ts | 119 ++++++ sidecar/nodes.ts | 142 +++++++ sidecar/normalize.ts | 187 +++++++++ sidecar/policy.ts | 115 ++++++ sidecar/routes.ts | 80 ++++ sidecar/servers.ts | 201 ++++++++++ sidecar/ssh.ts | 86 ++++ sidecar/users.ts | 104 +++++ sidecar/version.ts | 79 ++++ web/Cards.tsx | 103 +++++ web/ConsoleView.tsx | 96 +++++ web/DiagnosticsView.tsx | 326 +++++++++++++++ web/HeadscaleNav.tsx | 75 ++++ web/HeadscaleServerPicker.tsx | 60 +++ web/HeadscaleView.tsx | 37 ++ web/HeadscaleViewHeader.tsx | 23 ++ web/InvitesView.tsx | 394 ++++++++++++++++++ web/KeysView.tsx | 341 ++++++++++++++++ web/NodesView.tsx | 436 ++++++++++++++++++++ web/PolicyAssistant.tsx | 177 ++++++++ web/PolicyView.tsx | 210 ++++++++++ web/ServerForm.tsx | 226 +++++++++++ web/ServersView.tsx | 262 ++++++++++++ web/UsersView.tsx | 203 ++++++++++ web/ViewShell.tsx | 77 ++++ web/diff.ts | 77 ++++ web/format.ts | 35 ++ web/layout.ts | 22 + web/panels.ts | 27 ++ web/shared.ts | 225 +++++++++++ web/useHeadscaleCompanion.ts | 165 ++++++++ web/useHeadscaleData.ts | 163 ++++++++ web/useHeadscaleInvites.ts | 59 +++ web/useHeadscalePolicy.ts | 79 ++++ web/useHeadscaleSection.ts | 12 + web/useHeadscaleServers.ts | 102 +++++ 50 files changed, 7188 insertions(+) create mode 100644 .gitignore create mode 100644 PLUGIN.md create mode 100644 api/router.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 sidecar/active.ts create mode 100644 sidecar/assist.ts create mode 100644 sidecar/claude-proxy.ts create mode 100644 sidecar/client.ts create mode 100644 sidecar/companion.ts create mode 100644 sidecar/enroll.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/invites.ts create mode 100644 sidecar/keys.ts create mode 100644 sidecar/nodes.ts create mode 100644 sidecar/normalize.ts create mode 100644 sidecar/policy.ts create mode 100644 sidecar/routes.ts create mode 100644 sidecar/servers.ts create mode 100644 sidecar/ssh.ts create mode 100644 sidecar/users.ts create mode 100644 sidecar/version.ts create mode 100644 web/Cards.tsx create mode 100644 web/ConsoleView.tsx create mode 100644 web/DiagnosticsView.tsx create mode 100644 web/HeadscaleNav.tsx create mode 100644 web/HeadscaleServerPicker.tsx create mode 100644 web/HeadscaleView.tsx create mode 100644 web/HeadscaleViewHeader.tsx create mode 100644 web/InvitesView.tsx create mode 100644 web/KeysView.tsx create mode 100644 web/NodesView.tsx create mode 100644 web/PolicyAssistant.tsx create mode 100644 web/PolicyView.tsx create mode 100644 web/ServerForm.tsx create mode 100644 web/ServersView.tsx create mode 100644 web/UsersView.tsx create mode 100644 web/ViewShell.tsx create mode 100644 web/diff.ts create mode 100644 web/format.ts create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/shared.ts create mode 100644 web/useHeadscaleCompanion.ts create mode 100644 web/useHeadscaleData.ts create mode 100644 web/useHeadscaleInvites.ts create mode 100644 web/useHeadscalePolicy.ts create mode 100644 web/useHeadscaleSection.ts create mode 100644 web/useHeadscaleServers.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..969f5d9 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,733 @@ +# Offscale — the first real plugin + +**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from +the session that built the plugin system. Correct it in place; it is meant to be edited, not archived. + +It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin +system generally rather than about offscale, and that is deliberate: this is the worked example, and the +reasoning is most useful next to the code it produced. The platform's own docs should not carry the +history of something it no longer knows exists. + +Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical +slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological. + +**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside +the Headscale server and add what Headscale itself does not do, the invite flow being the first of them. +Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is +stock. The distinct name marks a distinct product, not a badge on someone else's. + +Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its +"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going. + +--- + +## The reframe + +**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`, +`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the +intuition is that it is one plugin requiring two sidecars. + +The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`, +the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was +written on runs five. + +### Two words, because "core" was doing two jobs + +- **baseline** — what a fresh install actually runs +- **first-party** — what Officer Dev publishes + +They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes +"is X core?" a question with two answers. + +--- + +## What a plugin is made of + +Combined per plugin as needed. **Only `meta` and the ID are always required.** + +- a **meta** object — id, name, dock item, backend/frontend mount, etc. +- an **ID** (see below) +- a **sidecar** +- a **backend router** and its routes +- a **db schema** +- **default permissions per user group** +- what it stores in the **secret store**, and whether that is per-user or plugin-global +- a **frontend router**, its routes, and the frontend code +- how it **mounts into the file browser context menu** +- a set of **permissions added to officer-items** +- **plugin settings page** definitions +- an accompanying **mobile app** + +A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers +mount, whether its sidecar is in the ecosystem file, and so on. + +### What offscale needs + +db schema · backend router + routes · frontend router + routes · sidecar. + +**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page. + +--- + +## Identity and routing + +**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its +route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee +across two namespaces means whichever is weaker becomes the real attack surface. + +**Uniqueness comes from two mechanisms**, because one is not enough: + +- **globally** — the marketplace owns the namespace for published names, with human review. A name as + generic as `notes` gets refused: it is a name Officer Dev may want later. +- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine. + Needed because a private plugin never asks the marketplace anything. + +The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no +restrictions; publishing is what invites review. + +### Mount prefixes + +``` +first-party /api/ e.g. /api/offscale +third-party /api/p// e.g. /api/p/alice/notes +``` + +`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns +that namespace anyway, and because provenance is then legible at a glance in a log or a route table. + +**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party +plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a +special case in the router, a bypassed check, a different install branch — first-party and third-party +become two systems, and only one of them gets tested. + +`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it +guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep +adding core routes forever without breaking installs. + +--- + +## The database + +**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase +already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery. + +### A Postgres schema per plugin was tested and rejected + +Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it +earns. Recorded so nobody re-runs the experiment: + +| Property | Result | +| ----------------------------------------------------------------- | ------------------------- | +| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works | +| Cross-schema FK to `public.users` | works | +| Partial unique index preserved | works | +| Push is idempotent, no spurious re-creation | works | +| Cascade delete across the schema boundary | works | +| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched | + +**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config +documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is +false. A push with the table verifiably exported reported `No changes detected` and created nothing; +naming the schema in `schemaFilter` made the identical push work. + +If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and +silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the +costume of a normal result. + +--- + +## Mounting — rebuild and swap, at runtime + +**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not +mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone. + +### What was actually tested + +| Router | `app.route()` after serving has begun | +| -------------------------------- | --------------------------------------------------------------------- | +| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` | +| `RegExpRouter` | **throws**, same reason | +| `TrieRouter` | works | +| `PatternRouter` | works | + +So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to +remove a route**, which uninstall needs. + +### The approach that solves both + +Rebuild the whole app from the current plugin set and **reassign the variable**: + +```ts +let app = buildApp(installedPlugins()); // core routes + one .route() per plugin +serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch + +// install: app = buildApp([...installed, 'offscale']) +// uninstall: app = buildApp(installed.filter(p => p !== 'offscale')) +``` + +The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end: + +``` +no plugins /offscale/x -> 404 | /core -> 200 +installed /offscale/x -> 200 | /core -> 200 +uninstalled /offscale/x -> 404 | /core -> 200 +``` + +Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast +`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express. + +### The one line that has to change + +`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has +to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all. +This is the whole mechanical cost. + +### Websockets are a separate table, and they reload + +Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`, +`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`. +The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions: + +``` +before reload /api/offscale/ws -> refused | /core -> 200 +after reload /api/offscale/ws -> CONNECTED | /core -> 200 +after remove /api/offscale/ws -> refused | /core -> 200 +``` + +So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start. +`reload` wants the whole option set, so `fetch` is passed alongside `routes`. + +`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing +before a plugin install can interrupt somebody's terminal. + +The two tables remain two lists, which is the same seam as the totality bug below. + +### What this means for `assertPermissionTotality` + +It can no longer be only a boot check, because the mount set changes after boot. The question moves to +**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that +every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted +where mounting actually happens instead of once at start-up. + +Two things it must survive, both live today: + +- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An + uninstalled plugin's routes are not mounted, so nothing can reach them. +- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun + serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into + `buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that + still needs pointing at reality. + +--- + +## Permissions + +A plugin declares permissions. **A plugin may declare `app`, and nothing else.** + +`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not +deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious +plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the +platform's to assign. + +### The platform grants read or write. Everything richer is the plugin's own job + +The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a +permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in +the surface; `write` permits everything. + +Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records, +visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the +platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data +means; the platform only knows whether this account got through the door. + +### Offscale v1 uses that model exactly, with nothing added + +One shared resource, role-gated: + +- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy +- **write** — can change them, including deleting a server the owner registered + +The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin** +key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on +offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected +use is read for most roles. Say Developers get `read` and nobody gets `write`. + +Two implementation consequences, both inside the plugin: + +1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today — + `listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user + because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id + always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted. + +2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:** + - `POST /ssh-test` — a reachability probe that mutates nothing + - `POST /policy/assist` — proposes a document and, emphatically, never saves one + + Without them a read-level account cannot test a connection or draft a policy, which reads as a broken + feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire, + delete, policy `PUT` — is a genuine write. + +### Music is where the richer model gets designed + +Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is +the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites, +playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in +`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there, +where offscale's is just "the owner's". + +Not designed yet, and deliberately not designed here. Recorded so the intent survives. + +### Several different things were called "capability" here + +A manifest needs three names, not one: + +1. `permissions/registry.ts` — **permissions** (`headscale`, `vpn`) +2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks) +3. `sidecar-registry` `handles: ['music']` — **routing keys** for `sendCommand`, renamed from + `capabilities` on 2026-08-15 + +Offscale needs (1) and (3), and not (2). + +--- + +## Secrets + +Two stores, and a plugin author will reach for the wrong one unless told: + +- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`, + `hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values. +- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed + `(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot + see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third + parties touch it. + +Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from +the host: + +```ts +import { db } from '../db'; // the connection +import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses +``` + +A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not +carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin +rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of +the pilot rather than being invented. + +--- + +## `/api/vpn` is being deleted + +Officer had two headscale surfaces: + +| | `/api/vpn` | `/api/headscale` | +| ---------- | ---------------------------------------- | -------------------------------------- | +| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only | +| purpose | enrol your own device | the tailnet: machines, routes, ACLs | +| surface | one route, `POST /enroll` | the whole admin API | + +`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller +anywhere.** Verified against the mobile monorepo: + +1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()` +2. `enroll()` is reached only via `if (embedded) await enroll()` +3. `embedded` is optional and defaults to `false` +4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it + +`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither +does the Officer web app. The live database holds no `vpn` grants. + +**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no +dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that +gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both. + +### Everything collapses to one namespace + +`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the +contract has no counterparty. + +**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls +`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the +invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites. + +- **phone → Companion** — untouched by anything here +- **web admin → Officer → sidecar** — ours to rename freely + +### There are THREE components, not two + +Easy to miss, and worth stating because two of them contain the word "enroll": + +| Component | Repo | Enrolment surface | +| ---------------- | ---------------------------- | ------------------------------------------------ | +| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only | +| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer | +| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` | + +The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero +references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's +`/health`. It never calls Officer and does not use `/api/offscale/*` either. + +**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at +`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put +in the invite (`https:///officer-api`). + +**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches +`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare +`POST /_officer/enroll` handler below it is dead. + +**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth +(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT. +The exemption must be declared with a reason or the boot check refuses. Not needed today. + +**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that +is correct. The invite flow supersedes it completely: + +1. the Officer headscale app holds an admin API key for the Headscale server +2. from it the owner mints an **invite** — a URL pointing at the Companion +3. the Companion turns that into the redirect the phone app claims +4. the device joins + +That path needs no per-member permission on Officer at all, and it is the one that exists and works. +`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was +one. Do not reintroduce a member-facing enrolment route on the assumption something is missing. + +--- + +## What headscale actually is — the inventory + +Read end to end on 2026-08-14. This is what has to move. + +### Backend — 2,406 lines + +`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app +logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers — +`servers · nodes · users · keys · policy · enroll · ssh-test · companion`. + +Three things worth knowing before touching it: + +- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and + never passed as a parameter — so no client can act on a server the owner is not currently looking at. +- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's: + uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403 + bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a + misspelled request field makes the call **succeed and do nothing** — which is why mutations read the + object back. One file containing all of it is the model for a plugin's client layer, not something to + undo. +- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick + is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it + branches on whether the body parses. + +Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`, +`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary. + +### Frontend — 29 files, 27 endpoints + +Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all +`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on +`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy · +Diagnostics · Console. + +It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section +lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active +server is a DB write that re-scopes every query, so it stays a button rather than a URL. + +The whole frontend↔host coupling, which becomes the plugin API: + +| Import | Why it matters | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` | +| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress | +| `AppRegistryMeta` | the panel-contribution contract | +| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` | +| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided | +| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app | + +### `assist.ts` travels, but stays unwired + +The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and +do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately +later. Do not tidy it away as unused code. + +--- + +## The manifest — proposal + +Written against offscale rather than invented in the abstract, on the principle that a field list designed +from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this +is the floor, not the ceiling. + +```ts +// plugins/offscale/manifest.ts +export const manifest = { + /** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */ + publisher: 'officerdev', + /** The plugin's own semver. Updates compare against this. */ + version: '1.0.0', + /** Which platforms this build is good for. Refused at install when it does not match. */ + platform: '>=1.0.0 <2.0.0', + + label: 'Offscale', + summary: 'Your tailnet — machines, users, pre-auth keys and access policy', + icon: 'Network', + color: '#818cf8', + + // Named `permissions`, NOT `capabilities`. That word meant several different things here — the + // permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be + // one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4. + permissions: [ + { + key: 'offscale', + label: 'Offscale', + description: 'The tailnet: machines, routes and ACLs', + /** Owner-only, or grantable to members. The whole distinction a plugin needs. */ + ownerOnly: true, + }, + ], +} as const; +``` + +### THE RULE: every plugin route renders a Workspace with at least one panel + +Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It +contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them. + +``` +web/panels.ts exports appRegistryMetas — at least one panel +web/layout.ts exports defaultLayout — how they are arranged +``` + +Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by +name and with the reason: + +``` +probeplug: has a web/ directory but is missing web/layout.ts. +Every plugin route renders a Workspace: contribute panels and a layout, not a screen. +``` + +There is deliberately no way to export a component. A plugin that could would be free to render a bare +div, a full-page form, its own navigation — and the platform would become a shell hosting strangers' +layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there +is nowhere to put a screen. + +The shell registers the pair `` and `/:section`, exactly as the core screens do +(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read +`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed` +is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather +than rendering another plugin's panel inside this one. + +### Everything the tree can say, the tree says + +The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something +a human chose. Everything structural is convention, and **presence is the declaration**: + +| Path | Means | +| -------------------- | --------------------------------------------------------------------------------------------- | +| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits | +| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below | +| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` | +| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` | +| `web/Router.tsx` | there is a frontend; its default export mounts at `/*` | +| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` | + +The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to: +mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was +duplication that could only ever drift. + +**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. +Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node +because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a +field that can contradict the file it describes. + +### Install asks nothing, and that is the default + +Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no +`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision +and no external service to point at. + +Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes. +Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a +Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already +how it works today. + +So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting +machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted +plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot: +it exercises the mounting, the schema and the sidecar without the install flow being a variable too. + +### Dropped from the first draft + +- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already + explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose + only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it + when something consumes it. +- **`kind`** — see below. +- **`sidecar` / `schema` / `frontend` objects** — all convention now. + +`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present +means a tile. Fine for offscale; add a flag the first time something needs it. + +### `admin` has to be allowed, and the pilot proved it immediately + +The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is +the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way. + +The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants +itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and +nothing is gained by forbidding it. + +Corrected rule: + +| Kind | May a plugin declare it? | Why | +| ----------- | ------------------------ | ---------------------------------------------------------- | +| `app` | yes | the ordinary grantable surface | +| `admin` | yes | self-restriction, never an escalation | +| `core` | **no** | every account, not deniable — an ungated grant to everyone | +| `execution` | **no** | runs as the owner's OS user; the platform's to assign | +| `confined` | **no** | implies a Linux identity the platform provisions | + +### One function decides the prefix + +`publisher` is the only input, so first-party and third-party cannot become two code paths: + +```ts +const mountPrefix = (m: Manifest) => + m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`; +``` + +Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance. + +### Notes on the fields + +- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while + everything else is bun. One plugin already needs it, so it is not speculative generality. +- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today; + 1.0 is expected before anyone outside Officer Dev writes a plugin. +- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds + inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already + degrade. This is for the human reading the store. +- **No `health`.** Deferred; process-online is what the store knows and that is enough for now. +- **No `migrations`.** Deferred; a field can be added without redesign. +- **No permission list.** A plugin calls the API with the user's token and the user's permissions. + +--- + +## What is built — complete, as of 2026-08-15 + +**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live +server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each +time. + +| Piece | Where | +| --------------------------------------- | -------------------------------------------------- | +| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` | +| Discovery by convention | `servers/plugins/discover.ts` | +| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` | +| Install runner, four verbs, streamed | `servers/plugins/install.ts` | +| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` | +| Schema barrel + `db:push` | `servers/plugins/schema.ts` | +| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` | +| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` | +| Permission registration | `permissions/registry.ts` → `setPluginPermissions` | +| Install state | `plugin_installs` | +| The screen | `/plugins`, two panels, SSE log | +| The reference plugin | `plugins/example/` | +| **The first real plugin** | `plugins/offscale/` — 45 files | + +Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the +frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the +whole thing survives a restart because boot regenerates and mounts before `serve()`. + +### Three bugs the extraction found + +Worth recording because none were visible from reading: + +1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot + `:server` event and subscribes when the plugin's router is first imported — at mount. So the + announcement fired into a void: process online, routes mounted, every request `503 sidecar not +available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it + has no listener to announce. Install and enable now mount first. +2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which + applies to Bun's static serving and not to a programmatic `Bun.build()`. +3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left + nothing, and two overlapping builds could delete each other's shell. It now stages and swaps. + +### Still open + +- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is + still the hardcoded providers. No plugin owns a socket yet. +- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads + `Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into + `buildHonoApp`, which is now the single place routes are mounted. +- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the + plugin system. One when the app store is rebuilt on this. +- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the + OWNER's rows rather than the caller's, which is a change inside the plugin. + +--- + +## The state of the app store, as found + +It **is** the plugin system, roughly 90% built, with one structural hole. + +`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the +installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and +silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`: + +> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job +> and it is not built."_ + +Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is +written, assets publish, the dock tile appears — and the sidecar never starts. + +Also found: + +- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via + `bun db:push`. +- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`). + `slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never + that the directory exists. +- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented + schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally + means editing two files and rebuilding. +- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/`. A plugin in + its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted. +- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans + `src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still + renders against it. Not to be confused with any of the above. + +--- + +## Where the code lives + +`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`. + +The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the +org object), so `/` is the only real namespace it has. Topics work and are searchable, and are +used in addition rather than instead — they span orgs, which matters because browser extensions under +`extensions/` may become plugins later. + +--- + +## Open questions + +1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`, + rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe: + everything compiles together and a plugin changes what "everything" is. The developer builds inside a + platform checkout, so dev-time and build-time are the same mechanism. +2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and + something has to apply schema changes over time. Cheap now, miserable to retrofit. +3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the + thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that + question. But process-online covers the common failure, every plugin that needs more surfaces it in its + own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant + future, not before. +4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is + still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView` + → `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between + plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the + Console section does not travel with the plugin. +5. **`service_connections.service` namespacing** before third parties touch it. +6. **`officer-anthropic-proxy`** — one plugin, two sidecars. +7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is + not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by + us" needs an answer, and the store deliberately refuses to adopt directories it did not create. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..3d3a09a --- /dev/null +++ b/api/router.ts @@ -0,0 +1,18 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; + +// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge: +// this file must never grow app logic. +// +// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs +// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform +// does not know Headscale's URL. + +const proxy = createSidecarProxy({ + name: 'headscale', + prefix: '/api/offscale', +}); + +export const router = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getHeadscaleServerUrl = proxy.getHttpUrl; diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..ba3b155 --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,186 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { headscaleServers } from './schema'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; + +// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT — +// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto. +// See ../crypto.ts and ../schema/headscale.ts. +// +// Two return types on purpose: +// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all. +// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned +// by a route handler. +// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column +// into every list response the moment someone forgot to strip it. + +export type HeadscaleServer = { + id: number; + name: string; + url: string; + version: string | null; + sshHost: string | null; + isActive: boolean; + lastSeenAt: Date | null; + createdAt: Date; +}; + +export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string }; + +const serverCols = { + id: headscaleServers.id, + name: headscaleServers.name, + url: headscaleServers.url, + version: headscaleServers.version, + sshHost: headscaleServers.sshHost, + isActive: headscaleServers.isActive, + lastSeenAt: headscaleServers.lastSeenAt, + createdAt: headscaleServers.createdAt, +}; + +/** Every server the owner has registered, active first then newest. Never includes the API key. */ +export async function listHeadscaleServers(userId: number): Promise { + return db + .select(serverCols) + .from(headscaleServers) + .where(eq(headscaleServers.userId, userId)) + .orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt)); +} + +/** The currently selected server with its key decrypted, or null when none is registered/active. */ +export async function getActiveHeadscaleCredentials(userId: number): Promise { + const [row] = await db + .select() + .from(headscaleServers) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + if (!row) return null; + return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; +} + +/** One server's credentials by id — for probing a specific server rather than the active one. */ +export async function getHeadscaleCredentials(userId: number, id: number): Promise { + const [row] = await db + .select() + .from(headscaleServers) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); + if (!row) return null; + return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; +} + +type CreateHeadscaleServerParams = { + userId: number; + name: string; + url: string; + apiKey: string; + version: string | null; + /** Optional SSH target for the console. Null when the owner hasn't set one. */ + sshHost: string | null; + /** Make it the active server. True for the first registration, so the UI is never left with none selected. */ + activate: boolean; +}; + +/** Register a server. The key is encrypted before write; the returned row carries no key. */ +export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise { + const { userId, name, url, apiKey, version, sshHost, activate } = params; + return db.transaction(async (tx) => { + if (activate) { + await tx + .update(headscaleServers) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + } + const [row] = await tx + .insert(headscaleServers) + .values({ + userId, + name, + url, + apiKey: encryptSecret('headscale', apiKey), + version, + sshHost, + isActive: activate, + lastSeenAt: version ? new Date() : null, + }) + .returning(serverCols); + return row!; + }); +} + +// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay +// distinguishable, which is why this is `string | null` and not `string`. +type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null }; + +/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */ +export async function updateHeadscaleServer( + userId: number, + id: number, + params: UpdateHeadscaleServerParams, +): Promise { + const set: Record = { updatedAt: new Date() }; + if (params.name !== undefined) set.name = params.name; + if (params.url !== undefined) set.url = params.url; + if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey); + if (params.sshHost !== undefined) set.sshHost = params.sshHost; + + const [row] = await db + .update(headscaleServers) + .set(set) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) + .returning(serverCols); + return row ?? null; +} + +/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */ +export async function setActiveHeadscaleServer(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + await tx + .update(headscaleServers) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + const [row] = await tx + .update(headscaleServers) + .set({ isActive: true, updatedAt: new Date() }) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) + .returning(serverCols); + return row ?? null; + }); +} + +/** + * Drop a registration. If it was the active one, the newest survivor is promoted — otherwise deleting the + * active server would leave the UI with servers registered but none selected, which reads as "not + * configured" and is a confusing place to land. + */ +export async function deleteHeadscaleServer(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + const [deleted] = await tx + .delete(headscaleServers) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) + .returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive }); + if (!deleted) return false; + + if (deleted.wasActive) { + const [next] = await tx + .select({ id: headscaleServers.id }) + .from(headscaleServers) + .where(eq(headscaleServers.userId, userId)) + .orderBy(desc(headscaleServers.createdAt)) + .limit(1); + if (next) { + await tx + .update(headscaleServers) + .set({ isActive: true, updatedAt: new Date() }) + .where(eq(headscaleServers.id, next.id)); + } + } + return true; + }); +} + +/** Record a successful reachability probe: the version observed and when we last reached the server. */ +export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise { + await db + .update(headscaleServers) + .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) + .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); +} diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..08376bb --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,55 @@ +import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from 'officerdb/auth/schema'; + +// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single +// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and +// toggles between them, so this is configuration the user creates at runtime rather than env vars. +// +// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted +// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to +// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key +// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext +// email/integrations tables are debt to avoid copying, not a precedent to follow. +// +// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own +// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the +// officer-headscale sidecar reads or writes these tables. + +export const headscaleServers = pgTable( + 'headscale_servers', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + // Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator. + url: text('url').notNull(), + apiKey: text('api_key').notNull(), // encrypted + // Last version seen from the server's unauthenticated GET /version. Null until first probed; the + // literal 'dev' when the server was built without VCS info, which is unknown rather than too-old. + version: text('version'), + // Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the + // API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately + // NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname + // stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the + // connection uses whatever ~/.ssh already knows, so there is no credential here to protect. + sshHost: text('ssh_host'), + isActive: boolean('is_active').notNull().default(false), + // Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't". + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // One registration per URL — re-registering the same server should be an edit, not a duplicate. + uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url), + // At most one active server per owner, enforced by the DB rather than by convention: a partial unique + // index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction, + // but a bug there fails loudly here instead of silently leaving two servers active. + uniqueIndex('uq_headscale_servers_one_active') + .on(t.userId) + .where(sql`${t.isActive}`), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..2a01d9a --- /dev/null +++ b/manifest.ts @@ -0,0 +1,47 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +// Offscale — Headscale, plus the Companion that ships beside it. +// +// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what +// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a +// distinct product rather than a badge on someone else's. +// +// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here: +// +// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any +// sidecar/ the whole Headscale contract, holding the admin API keys +// db/ offscale_servers, and the only table this plugin owns +// web/ panels and a layout; the shell renders the Workspace +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Offscale', + summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites', + icon: 'Network', + color: '#818cf8', + + // One permission gating the whole surface, grantable per role at read or write like every other. + // + // `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries + // still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own + // empty server list rather than the owner's, and could register a Headscale of their own. The model in + // ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it. + // That is a change inside these queries, not a flag on the manifest. + // + // Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can + // delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to + // full control of the tailnet, which is the owner's decision to make deliberately. + permissions: [ + { + key: 'offscale', + label: 'Offscale', + description: 'The tailnet: machines, routes, keys and ACLs', + // Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves. + // Without declaring them a read-level account meets a broken feature where a withheld permission + // should be. Inert while ownerOnly, and correct the moment that changes. + readOnlyWrites: ['/ssh-test', '/policy/assist'], + }, + ], +}; diff --git a/sidecar/active.ts b/sidecar/active.ts new file mode 100644 index 0000000..d41e781 --- /dev/null +++ b/sidecar/active.ts @@ -0,0 +1,20 @@ +import { getActiveHeadscaleCredentials } from '../db/queries'; +import { createClient, type HeadscaleClient } from './client'; + +// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That +// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so +// no client can act on a server the owner isn't currently looking at by guessing an id. + +/** + * The client for the active server, or a ready-to-send 409 when there isn't one. + * + * 409 rather than 404: the route exists and the request was well-formed, the account just has no server + * selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone". + */ +export async function activeClient(userId: number): Promise { + const creds = await getActiveHeadscaleCredentials(userId); + if (!creds) { + return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); + } + return createClient(creds); +} diff --git a/sidecar/assist.ts b/sidecar/assist.ts new file mode 100644 index 0000000..c2d220f --- /dev/null +++ b/sidecar/assist.ts @@ -0,0 +1,153 @@ +import type { OfficerContext } from './routes'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { activeClient } from './active'; +import { arrayField, toNode, toUser } from './normalize'; +import { askClaude, ProxyUnavailable } from './claude-proxy'; + +// `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back. +// +// The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar, +// and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not +// "typing is slow", it is "I do not know what the file is supposed to look like". +// +// THREE THINGS THIS DELIBERATELY DOES NOT DO. +// +// 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is +// written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff. +// A model that could write the ACL directly is a model that can partition the network the owner is +// connected through — including the SSH route back in to fix it. +// 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a +// proposal that looks fine here and is refused on save is a normal, visible outcome. +// 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the +// rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses. +// +// The current document is sent in full and the reply must be the full replacement, not a patch. Patches +// against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this +// file's whole premise is that those are worth keeping. + +const MODEL = 'claude-sonnet-5'; +const MAX_TOKENS = 8_000; +/** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */ +const MAX_PROMPT_CHARS = 2_000; +/** Enough context to write rules against without pasting an entire large tailnet into the request. */ +const MAX_NODES = 60; + +const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy. + +The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format: +groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS +features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic +"acls" entries unless the existing document already uses grants. + +Rules for your reply, in this order: + +1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or + denies. No preamble, no restating the request. +2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an + excerpt — the whole file, ready to replace what is there. + +Preserve the existing document's comments, key order and indentation wherever your change does not touch +them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist +in the context given to you, or that you also define in the same document. If the request is ambiguous enough +that you would have to guess at something consequential, say so in the paragraph and make the narrower, +safer choice rather than asking a question — the owner reviews a diff before anything is saved. + +If the request cannot be expressed in this policy at all, say why in the paragraph and return the document +unchanged in the code block.`; + +type TailnetContext = { users: string[]; tags: string[]; nodes: string[] }; + +/** + * The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are + * called. Best-effort — a server that will not answer these still gets an assistant, just a less informed + * one, which beats failing the request over context that is an optimisation. + */ +async function readContext(userId: number): Promise { + const client = await activeClient(userId); + if (client instanceof Response) return { users: [], tags: [], nodes: [] }; + + try { + const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); + + const users = arrayField(userBody, 'users') + .map((raw) => toUser(raw)?.name) + .filter((name): name is string => !!name); + + const nodes = arrayField(nodeBody, 'nodes').map(toNode); + const tags = [...new Set(nodes.flatMap((node) => node.tags))].sort(); + const named = nodes.slice(0, MAX_NODES).map((node) => { + const owner = node.user?.name ?? 'unknown'; + const tagged = node.tags.length ? ` [${node.tags.join(' ')}]` : ''; + return `${node.name} (user: ${owner})${tagged}`; + }); + + return { users, tags, nodes: named }; + } catch { + return { users: [], tags: [], nodes: [] }; + } +} + +function buildPrompt(policy: string, request: string, context: TailnetContext): string { + const lines = [ + 'Current policy document:', + '```hujson', + policy.trim() || '// (this server has no policy yet)', + '```', + '', + 'This tailnet:', + `- users: ${context.users.length ? context.users.join(', ') : '(none)'}`, + `- tags in use: ${context.tags.length ? context.tags.join(', ') : '(none)'}`, + `- machines: ${context.nodes.length ? context.nodes.join('; ') : '(none)'}`, + ]; + if (context.nodes.length === MAX_NODES) lines.push(` (first ${MAX_NODES} shown)`); + lines.push('', 'Requested change:', request.trim()); + return lines.join('\n'); +} + +/** + * Split the reply into the explanation and the document. + * + * The fence is the contract, so a reply without one is a failure to report rather than something to salvage: + * feeding half an answer into the editor as if it were a policy is worse than saying the model didn't comply. + */ +function splitReply(text: string): { explanation: string; policy: string } | null { + const match = text.match(/```(?:hujson|json|jsonc)?\s*\n([\s\S]*?)```/); + if (!match || !match[1]?.trim()) return null; + return { explanation: text.slice(0, match.index).trim(), policy: match[1].replace(/\s+$/, '') }; +} + +/** `POST /_officer/policy/assist {prompt, policy}` → `{explanation, policy}`. Nothing is written upstream. */ +export async function handlePolicyAssistRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length > 0) return badRequest('unexpected path'); + if (ctx.req.method !== 'POST') return methodNotAllowed(); + + const body = await readJson(ctx.req); + const request = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; + if (!request) return badRequest('prompt is required'); + if (request.length > MAX_PROMPT_CHARS) return badRequest(`prompt must be under ${MAX_PROMPT_CHARS} characters`); + // The draft on screen, not the saved document: the owner may have edited it, and a proposal built against + // a version they cannot see would come back as a diff full of changes they never asked for. + const policy = typeof body?.policy === 'string' ? body.policy : ''; + + const context = await readContext(ctx.userId); + + try { + const reply = await askClaude({ + model: MODEL, + maxTokens: MAX_TOKENS, + system: SYSTEM, + prompt: buildPrompt(policy, request, context), + }); + + const split = splitReply(reply); + if (!split) { + return Response.json({ error: 'the model did not return a policy document — try rephrasing' }, { status: 502 }); + } + return Response.json({ explanation: split.explanation, policy: split.policy }); + } catch (err) { + if (err instanceof ProxyUnavailable) { + return Response.json({ error: err.message, code: 'assistant_unavailable' }, { status: 503 }); + } + throw err; + } +} diff --git a/sidecar/claude-proxy.ts b/sidecar/claude-proxy.ts new file mode 100644 index 0000000..c047295 --- /dev/null +++ b/sidecar/claude-proxy.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_PATH } from '@@/data-path'; +import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs'; + +// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. +// +// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It +// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally +// generated secret it writes to its own state file, so authenticating is a file read, not a credential this +// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts +// (`readProxySecretFromDisk`), which does the same thing for the agent. +// +// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this +// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle. +// +// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat +// surface, which already exists and already persists. + +const DEFAULT_TIMEOUT_MS = 120_000; + +/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */ +export class ProxyUnavailable extends Error {} + +/** + * The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a + * freshly installed machine has a window where the file exists without it. + */ +function readProxySecret(): string { + try { + const file = join(DATA_PATH, 'sidecar', 'claude-state.json'); + if (!existsSync(file)) return ''; + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown }; + return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : ''; + } catch { + return ''; + } +} + +type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number }; + +type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } }; + +/** + * One user turn, one reply, as plain text. + * + * The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates + * with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something + * else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is + * currently accepted too. Keeping the block costs ~14 tokens and removes the question.) + */ +export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise { + const secret = readProxySecret(); + if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment'); + + let res: Response; + try { + res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, { + method: 'POST', + headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + system: [ + { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }, + { type: 'text', text: system }, + ], + messages: [{ role: 'user', content: prompt }], + }), + signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long'); + throw new ProxyUnavailable('the Claude proxy is not reachable'); + } + + const body = (await res.json().catch(() => null)) as MessagesResponse | null; + + if (!res.ok) { + const detail = body?.error?.message; + // 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream + // saying something specific about this request, which is worth passing through. + if (res.status === 401 || res.status === 429) { + throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`); + } + throw new Error(detail ?? `the model returned ${res.status}`); + } + + const text = (body?.content ?? []) + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('') + .trim(); + + if (!text) throw new Error('the model returned an empty reply'); + return text; +} diff --git a/sidecar/client.ts b/sidecar/client.ts new file mode 100644 index 0000000..5e51fb3 --- /dev/null +++ b/sidecar/client.ts @@ -0,0 +1,113 @@ +import type { HeadscaleServerCredentials } from '../db/queries'; + +// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the +// wire-level quirks are handled once: +// +// • Auth is `Authorization: Bearer `. Headscale's swagger declares no securityDefinitions at all, +// so a generated client would omit it entirely. +// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is +// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the +// auth failure you most want to report clearly. +// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as +// strings end to end and never round-trip them through Number, which would silently break above 2^53. +// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather +// than being omitted. You cannot distinguish "unset" from "empty" — don't try. +// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected. +// Silent no-ops are the failure mode; mutations here read the object back where the API returns it. + +const DEFAULT_TIMEOUT_MS = 15_000; + +/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */ +export class HeadscaleError extends Error { + constructor( + readonly status: number, + message: string, + /** + * Headscale's own words, kept even when `message` generalizes them. + * + * A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are + * the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's + * line and column with the same 500, and there the message IS the feature. Callers that know their + * endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error". + */ + readonly detail?: string, + ) { + super(message); + this.name = 'HeadscaleError'; + } +} + +type CallOptions = { method?: string; body?: unknown; timeoutMs?: number }; + +/** + * Extract a human-usable message from a Headscale error response, tolerating both of its formats. + * Never returned verbatim to the browser for auth failures — see callers. + */ +async function errorMessage(res: Response): Promise { + const text = await res.text().catch(() => ''); + if (!text) return `upstream returned ${res.status}`; + try { + const parsed = JSON.parse(text) as { message?: unknown }; + if (typeof parsed.message === 'string' && parsed.message) return parsed.message; + } catch { + /* plain text — the 401 case */ + } + return text.slice(0, 300); +} + +export type HeadscaleClient = { + readonly serverId: number; + /** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */ + call: (path: string, opts?: CallOptions) => Promise; +}; + +/** Build a client bound to one registered server's credentials. */ +export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient { + async function call(path: string, opts: CallOptions = {}): Promise { + const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; + + const headers: Record = { + authorization: `Bearer ${creds.apiKey}`, + accept: 'application/json', + }; + if (body !== undefined) headers['content-type'] = 'application/json'; + + let res: Response; + try { + res = await fetch(`${creds.url}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const timedOut = err instanceof Error && err.name === 'TimeoutError'; + throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable'); + } + + if (res.status === 401 || res.status === 403) { + // The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an + // Officer-side auth problem — the UI should point the owner at re-entering the key. + throw new HeadscaleError(502, 'headscale rejected the stored API key'); + } + + if (!res.ok) { + const message = await errorMessage(res); + console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`); + // 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized. + const serverSide = res.status >= 500; + throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message); + } + + // 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all. + const text = await res.text(); + if (!text) return {} as T; + try { + return JSON.parse(text) as T; + } catch { + throw new HeadscaleError(502, 'headscale returned a non-JSON body'); + } + } + + return { serverId: creds.id, call }; +} diff --git a/sidecar/companion.ts b/sidecar/companion.ts new file mode 100644 index 0000000..c44a21c --- /dev/null +++ b/sidecar/companion.ts @@ -0,0 +1,204 @@ +import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries'; +import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes'; + +// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the +// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it. +// Contract: COMMS/HEADSCALE_COMPANION_API.md. +// +// Three facts shape everything here. +// +// 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already +// store, validated locally against Headscale's own key store — so auth keeps working while Headscale +// is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never +// leaves this sidecar. +// +// 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So +// "no companion" is a normal state, not an error: every route below answers 200 with +// `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do. +// Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down, +// the companion returns JSON when a docker op fails. Branch on whether the body parses. +// +// 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read +// `verdict`. That inversion is deliberate on their side and is preserved on ours. + +/** + * Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a + * companion is a state to render, not a request that failed — the admin API on the same domain is + * independent and may still be working, so this must not surface as an error the UI swallows. + */ +export const unavailable = (reason: string) => ({ available: false as const, reason }); + +const DEFAULT_TIMEOUT_MS = 20_000; + +type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal }; + +/** + * One request to a server's companion. Returns the raw Response, or a reason string when the companion + * itself could not be reached — the caller decides how to present that, because for this feature + * "unreachable" is information rather than a failure. + */ +export async function callCompanion( + creds: HeadscaleServerCredentials, + { path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall, +): Promise { + let res: Response; + try { + res = await fetch(`${creds.url}/officer-api${path}`, { + method, + headers: { + authorization: `Bearer ${creds.apiKey}`, + accept: 'application/json', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ?? AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') return 'the companion timed out'; + // A TLS failure or DNS miss on the server's own domain: the whole host is unreachable, not just this. + return 'could not reach the server'; + } + + if (res.status === 401) return 'the companion rejected the stored API key'; + + // Both 404 and 502 are ambiguous, and the same test settles both: a JSON body means the companion + // answered (no such container / the docker op failed) and that answer belongs to the caller; a + // non-JSON body means we never reached it — nginx's own 502 page, or a route that isn't there at all. + const isJson = (res.headers.get('content-type') ?? '').includes('json'); + if (res.status === 404 && !isJson) return 'this server has no companion at /officer-api'; + if (res.status === 502 && !isJson) return 'the companion is not deployed on this server'; + if (res.status >= 500 && !isJson) return `the companion returned ${res.status}`; + return res; +} + +/** Parse a companion JSON body, or a reason when it isn't JSON after all. */ +export async function readBody(res: Response): Promise | string> { + const text = await res.text().catch(() => ''); + if (!text) return 'the companion returned an empty body'; + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object') return 'the companion returned an unexpected body'; + return parsed as Record; + } catch { + return 'the companion returned a non-JSON body'; + } +} + +/** The active server's credentials, or a 409 the UI already knows how to render. */ +export async function activeCreds(userId: number): Promise { + const creds = await getActiveHeadscaleCredentials(userId); + if (!creds) { + return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); + } + return creds; +} + +/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */ +async function health(creds: HeadscaleServerCredentials): Promise { + const res = await callCompanion(creds, { path: '/health' }); + if (typeof res === 'string') return Response.json(unavailable(res)); + + const body = await readBody(res); + if (typeof body === 'string') return Response.json(unavailable(body)); + // Passed through as-is. The companion owns this vocabulary and versions it; re-shaping it here would mean + // a new verdict or a new likely-cause silently disappearing on the way to the screen. + return Response.json({ available: true, health: body }); +} + +/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */ +async function logs(creds: HeadscaleServerCredentials, url: URL): Promise { + const tail = Number(url.searchParams.get('tail') ?? 200); + if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); + + const res = await callCompanion(creds, { path: `/logs?tail=${tail}` }); + if (typeof res === 'string') return Response.json(unavailable(res)); + + const body = await readBody(res); + if (typeof body === 'string') return Response.json(unavailable(body)); + const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : []; + return Response.json({ available: true, lines }); +} + +/** + * `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame. + * + * The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need + * is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a + * ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn. + * Buffering it into frames here would break that, and would also mean a log line waiting on our own flush. + */ +async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { + const tail = Number(ctx.url.searchParams.get('tail') ?? 200); + if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); + + // No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's + // own signal is the lifetime — when the panel closes, this closes. + const res = await callCompanion(creds, { + path: `/logs?tail=${tail}&follow=1`, + signal: ctx.req.signal, + }); + + // An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and + // one place to show a problem rather than a second, JSON-shaped failure mode. + if (typeof res === 'string') { + return new Response(`event: error\ndata: ${res}\n\n`, { + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, + }); + } + + return new Response(res.body, { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + // Belt and braces through our own proxy chain, matching what the companion already sets. + 'x-accel-buffering': 'no', + }, + }); +} + +const ACTIONS = new Set(['restart', 'stop', 'start']); + +/** + * `POST /_officer/companion/:action` — restart / stop / start the Headscale container. + * + * Every one of these drops every node's control-plane connection for the duration. That is the intended + * "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically. + */ +async function action(creds: HeadscaleServerCredentials, name: string): Promise { + // 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the + // one question this feature exists to answer. + const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 }); + if (typeof res === 'string') return Response.json(unavailable(res)); + + const body = await readBody(res); + if (typeof body === 'string') return Response.json(unavailable(body)); + return Response.json({ available: true, ...body }); +} + +/** Dispatch `/_officer/companion/...`. Always acts on the ACTIVE server, like every other domain route. */ +export async function handleCompanionRoute(ctx: OfficerContext, rest: string[]): Promise { + const creds = await activeCreds(ctx.userId); + if (creds instanceof Response) return creds; + + const [head, tail] = rest; + + if (head === 'health' && !tail) { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + return health(creds); + } + + if (head === 'logs') { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + if (!tail) return logs(creds, ctx.url); + if (tail === 'stream') return logStream(creds, ctx); + return notFound(); + } + + if (head && ACTIONS.has(head) && !tail) { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + return action(creds, head); + } + + return notFound(); +} diff --git a/sidecar/enroll.ts b/sidecar/enroll.ts new file mode 100644 index 0000000..eed8195 --- /dev/null +++ b/sidecar/enroll.ts @@ -0,0 +1,103 @@ +import type { OfficerContext } from './routes'; +import type { OfficerUser } from './normalize'; +import { getActiveHeadscaleCredentials } from '../db/queries'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { createClient, type HeadscaleClient } from './client'; +import { arrayField, toUser } from './normalize'; +import { handleInvitesRoute } from './invites'; + +// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated +// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand. +// +// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read +// HEADSCALE_URL, HEADSCALE_API_KEY +// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server, +// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some +// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks +// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now, +// like every other domain route here, and the platform holds no Headscale credentials at all. +// +// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core +// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and +// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not. + +/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */ +const KEY_TTL_MS = 10 * 60_000; + +/** + * Which Headscale user the joining device is filed under. + * + * An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the + * server means there is nothing to choose. Several means the caller has to say, because picking silently + * files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the + * tailnet. The old env var picked one name for every server at once, which is precisely that bug. + */ +async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + const requested = typeof body?.userId === 'string' ? body.userId.trim() : ''; + + const listed = await client.call('/api/v1/user'); + const users = arrayField(listed, 'users') + .map(toUser) + .filter((u): u is OfficerUser => !!u); + + if (requested) { + const match = users.find((u) => u.id === requested); + return match ?? badRequest(`no Headscale user with id ${requested} on the active server`); + } + + if (users.length === 1) return users[0]!; + + if (users.length === 0) { + return Response.json( + { error: 'the active Headscale server has no users — create one before enrolling a device', code: 'no_users' }, + { status: 409 }, + ); + } + + return Response.json( + { + error: 'the active Headscale server has several users — pass userId to say which one owns this device', + code: 'ambiguous_user', + users: users.map((u) => ({ id: u.id, name: u.name })), + }, + { status: 409 }, + ); +} + +export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise { + // `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device + // is not here and there is no Officer session on it. Same prefix because it is the same feature to the + // person using it, and because the spec names it that way. + if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1)); + + if (segments.length > 0) return null; + if (ctx.req.method !== 'POST') return methodNotAllowed(); + + // Not activeClient(): the control URL goes back to the device, and only the credentials carry it. + const creds = await getActiveHeadscaleCredentials(ctx.userId); + if (!creds) { + return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); + } + + const client = createClient(creds); + + const owner = await resolveOwner(client, ctx); + if (owner instanceof Response) return owner; + + const created = await client.call<{ preAuthKey?: { key?: string } }>('/api/v1/preauthkey', { + method: 'POST', + body: { + user: owner.id, + reusable: false, // one key, one device + ephemeral: false, // the node stays registered after it disconnects + expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339 + }, + }); + + const authKey = created.preAuthKey?.key; + if (!authKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 }); + + // `server` and `user` are advisory — for a UI that wants to say what the device just joined. + return Response.json({ controlUrl: creds.url, authKey, server: creds.name, user: owner.name }); +} diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..ebb5ff5 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,149 @@ +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/connect'; +import { handleOfficerRoute } from './routes'; +import { MIN_VERSION_LABEL } from './version'; +import { API_URL } from '@@/officer-url.mjs'; + +// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and +// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform +// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials. +// +// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key +// generated on that server, and switches between them; one is active at a time. So configuration lives in +// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads +// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env. +// Device enrollment used to be the exception, minting keys in the platform from those two vars plus +// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding. +// +// GET /_health ours. Sidecar liveness only. Per-server reachability is a +// different question and needs an owner, so it lives below. +// GET /_officer/servers registered servers (never includes API keys) +// POST /_officer/servers register {name?,url,apiKey} — validated before it is saved +// PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes +// DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active +// POST /_officer/servers/:id/activate switch the active server +// GET /_officer/servers/:id/health probe: reachable? version? key still accepted? +// +// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts. +// +// GET /_officer/nodes nodes, normalized; ?user= filters +// GET /_officer/nodes/:id one node +// DELETE /_officer/nodes/:id remove it from the tailnet +// POST /_officer/nodes/:id/rename {name} +// POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing +// POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here) +// POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete) +// GET /_officer/users users, each with a node count the admin API doesn't provide +// POST /_officer/users {name, displayName?, email?} +// POST /_officer/users/:id/rename {name} +// DELETE /_officer/users/:id refused upstream while the user still owns nodes +// GET /_officer/keys pre-auth keys, secrets masked, with a derived status +// POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?} +// → the ONLY response carrying the real secret +// POST /_officer/keys/:id/expire expire without deleting +// DELETE /_officer/keys/:id delete outright +// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key +// for a joining device. userId is only required when the server +// has more than one user. +// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll, +// which is deleted. Kept because it is the handler a route under +// /api/offscale would reuse, and because `/enroll/invites` — which +// IS live — dispatches through the same function. +// anything else 404 +// +// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly +// below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser +// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probe.port; + probe.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url); + + // Liveness, not upstream health: with many registered servers there is no single upstream to probe, and + // choosing one would need an authenticated owner. See /_officer/servers/:id/health for that. + if (url.pathname === '/_health') { + return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL }); + } + + if (url.pathname.startsWith('/_officer/')) { + try { + const res = await handleOfficerRoute(req, url); + return res ?? new Response('not found', { status: 404 }); + } catch (err) { + console.error(`[headscale] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + + return new Response('not found', { status: 404 }); + }, +}); + +console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`); + +// ── Command handlers ── + +type ReplyFn = (msg: SidecarEvent) => void; + +function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { + switch (cmd.type) { + case 'ping': + reply({ type: 'pong', id: cmd.id }); + break; + default: + reply({ + type: 'error', + id: (cmd as SidecarCommand).id, + error: `Unknown command type: ${(cmd as Record).type}`, + }); + } +} + +// ── Connect to API server ── + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'headscale', + handles: ['headscale'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, + onConnected() { + // Tell the API where we're listening, so it can forward /api/headscale/* here. + connection.send({ type: 'headscale:server', port }); + console.log(`[headscale] reported port ${port} to API`); + }, +}); + +// ── Graceful shutdown ── + +function shutdown(signal: string) { + console.log(`[headscale] ${signal} received, shutting down...`); + try { + server.stop(true); + } catch { + /* already stopped */ + } + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/sidecar/invites.ts b/sidecar/invites.ts new file mode 100644 index 0000000..37fed1d --- /dev/null +++ b/sidecar/invites.ts @@ -0,0 +1,183 @@ +import type { HeadscaleServerCredentials } from '../db/queries'; +import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; +import { activeCreds, callCompanion, readBody, unavailable } from './companion'; + +// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use +// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth +// key it never had to be told. +// +// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining +// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an +// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when +// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the +// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what +// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke. +// +// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond +// the moment it is handed to the admin." The create response passes through this process once, in memory, +// on its way to the browser — that is the whole of its life here. +// +// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other +// companion route: most registered servers have no companion at all, and that is a state to render rather +// than a request that failed. + +/** + * Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is + * `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container + * routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift. + */ +const INVITES_PATH = '/api/v1/enroll/invites'; + +/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */ +const DEFAULT_TTL_SECONDS = 900; +const MIN_TTL_SECONDS = 60; +const MAX_TTL_SECONDS = 86_400; + +type CreateInput = { + user: string; + ttlSeconds: number; + ephemeral: boolean; + tags: string[]; + note?: string; +}; + +/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */ +function parseCreate(body: Record | null): CreateInput | Response { + const user = typeof body?.user === 'string' ? body.user.trim() : ''; + if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user'); + + const raw = body?.ttlSeconds; + const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw); + if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) { + return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`); + } + + // Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and + // adding it here means the admin can type either form without minting a key that silently has no tag. + const tags = Array.isArray(body?.tags) + ? [ + ...new Set( + body.tags + .filter((t): t is string => typeof t === 'string') + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)), + ), + ] + : []; + + const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : ''; + + return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) }; +} + +/** + * Turn a companion answer into ours. + * + * The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false` + * (render an explanation), a companion refusal keeps its own status and message (the admin typed something + * the server rejected), and success is the body with `available: true` on it. + */ +async function relay(res: Response | string, wrap: (body: Record) => unknown): Promise { + if (typeof res === 'string') return Response.json(unavailable(res)); + + const body = await readBody(res); + if (typeof body === 'string') return Response.json(unavailable(body)); + + if (!res.ok) { + const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`; + return Response.json( + { error, code: typeof body.code === 'string' ? body.code : undefined }, + { status: res.status }, + ); + } + + return Response.json(wrap(body)); +} + +/** + * Carry the admin's device name in the link's fragment, as `n=`. + * + * The companion already knows the name — it stores the note and hands it back as `suggestedHostname` on + * claim — but a claim only happens when the person taps Join, which is one step AFTER the screen that asks + * them to name the device. So the name has to arrive with the link if the field is to be prefilled, and the + * link is the last thing that passes through here. + * + * Safe at every hop: the fragment is never sent to a server, the companion's /join page copies it verbatim + * into the `officer-offscale://` deep link, and a build of the app that predates this ignores an unknown + * parameter and still gets the name from `suggestedHostname` at claim time. Percent-encoded rather than + * base64url (which `s` uses) because the app's fragment parser already decodeURIComponent()s every value, + * and because base64url of a non-ASCII name would decode to mojibake on Hermes. + */ +function withNameHint(url: unknown, name: string | undefined): unknown { + if (typeof url !== 'string' || !name || !url.includes('#')) return url; + return `${url}&n=${encodeURIComponent(name)}`; +} + +/** + * `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. + * + * `url` is an ordinary HTTPS link to a page on the server's own domain, which bounces into the app; the + * companion also returns `deepLink`, the `officer-offscale://` scheme that page redirects to. That one is + * dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of + * a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it. + */ +async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { + const input = parseCreate(await readJson(ctx.req)); + if (input instanceof Response) return input; + + const res = await callCompanion(creds, { path: INVITES_PATH, method: 'POST', body: input }); + return relay(res, (body) => { + const raw = body.invite ?? body; + const invite = raw && typeof raw === 'object' ? (raw as Record) : {}; + const { deepLink: _deepLink, ...rest } = invite; + return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } }; + }); +} + +/** + * Pull the invite array out of whatever envelope the companion used. + * + * §4.3 specifies the fields but not the wrapper, and the create response came back flat (no `invite` key), + * so the list may equally be a bare array or sit under `invites`/`items`/`data`. Taking the first + * array-valued property is shape-agnostic without being credulous: the body has exactly one array in it. + */ +function pickInvites(body: Record): unknown[] { + if (Array.isArray(body)) return body; + for (const key of ['invites', 'items', 'data', 'results']) { + const value = body[key]; + if (Array.isArray(value)) return value; + } + const found = Object.values(body).find(Array.isArray); + return Array.isArray(found) ? found : []; +} + +/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */ +async function list(creds: HeadscaleServerCredentials): Promise { + const res = await callCompanion(creds, { path: INVITES_PATH }); + return relay(res, (body) => ({ available: true, invites: pickInvites(body) })); +} + +/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */ +async function revoke(creds: HeadscaleServerCredentials, id: string): Promise { + const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' }); + return relay(res, (body) => ({ available: true, ...body })); +} + +/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */ +export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise { + const creds = await activeCreds(ctx.userId); + if (creds instanceof Response) return creds; + + const [id, extra] = rest; + if (extra) return notFound(); + + if (!id) { + if (ctx.req.method === 'POST') return create(creds, ctx); + if (ctx.req.method === 'GET') return list(creds); + return methodNotAllowed(); + } + + if (ctx.req.method !== 'DELETE') return methodNotAllowed(); + return revoke(creds, id); +} diff --git a/sidecar/keys.ts b/sidecar/keys.ts new file mode 100644 index 0000000..dbf3e6d --- /dev/null +++ b/sidecar/keys.ts @@ -0,0 +1,119 @@ +import type { OfficerContext } from './routes'; +import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; +import { activeClient } from './active'; +import { toPreAuthKey, arrayField } from './normalize'; + +// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet. +// +// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real +// secret ONLY in the create response. Every later list returns it masked as `hskey-auth--***`. A +// creation response that the UI drops is a key the owner can never recover — it has to be shown once, with +// a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once. +// +// That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are +// still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards +// compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey +// reveals. A server with history in it would otherwise leak live keys into the browser's query cache. +// +// Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a +// query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes. + +/** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */ +const DEFAULT_EXPIRY_DAYS = 90; +const MAX_EXPIRY_DAYS = 3650; + +async function listKeys(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + // 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user). + const body = await client.call('/api/v1/preauthkey'); + const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false })); + + // Usable keys first, then by newest — a spent key is history, an active one is the thing you came for. + const rank = { active: 0, used: 1, expired: 2 } as const; + keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + + return Response.json({ keys }); +} + +async function createKey(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + + // CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The + // two are easy to confuse and the failure is a confusing upstream error, so it's validated here. + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user'); + + const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays); + if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) { + return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`); + } + + const aclTags = Array.isArray(body.aclTags) + ? body.aclTags + .filter((t): t is string => typeof t === 'string') + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)) + : []; + + const created = await client.call<{ preAuthKey?: Record }>('/api/v1/preauthkey', { + method: 'POST', + body: { + user: userId, + reusable: body.reusable === true, + ephemeral: body.ephemeral === true, + expiration: new Date(Date.now() + days * 86_400_000).toISOString(), + aclTags, + }, + }); + + if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 }); + + const key = toPreAuthKey(created.preAuthKey, { reveal: true }); + // Stated explicitly rather than left for the client to infer from `key !== null`: this response is the + // only time the secret exists anywhere outside the joining machine. + return Response.json({ key, secretShownOnce: true }, { status: 201 }); +} + +type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === 'expire') { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } }); + return new Response(null, { status: 204 }); + } + + if (action !== undefined) return notFound(); + + if (ctx.req.method === 'DELETE') { + await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + + return methodNotAllowed(); +} + +/** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */ +export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method === 'GET') return listKeys(ctx); + if (ctx.req.method === 'POST') return createKey(ctx); + return methodNotAllowed(); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric'); + + return handleKeyAction({ ctx, id, action: rest[1] }); +} diff --git a/sidecar/nodes.ts b/sidecar/nodes.ts new file mode 100644 index 0000000..dc34a2c --- /dev/null +++ b/sidecar/nodes.ts @@ -0,0 +1,142 @@ +import type { OfficerContext } from './routes'; +import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; +import type { HeadscaleClient } from './client'; +import { activeClient } from './active'; +import { toNode, arrayField, type OfficerNode } from './normalize'; + +// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet. +// +// Two upstream shapes are worth knowing before reading this: +// +// • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be +// encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route. +// • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an +// add/remove. Approving one route means sending every route that should remain approved, so those +// operations are read-modify-write here rather than in the browser — see rule 5 in +// SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits; +// doing it here still races, but over milliseconds instead of however long a form sits open. + +/** Nodes on the active server, newest-registered first within each user. */ +async function listNodes(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + // The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser. + const user = ctx.url.searchParams.get('user'); + const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node'; + + const body = await client.call(path); + const nodes = arrayField(body, 'nodes').map(toNode); + nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name)); + return Response.json({ nodes }); +} + +/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */ +async function getNode(client: HeadscaleClient, id: string): Promise { + const body = await client.call<{ node?: Record }>(`/api/v1/node/${encodeURIComponent(id)}`); + return body.node ? toNode(body.node) : null; +} + +type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise { + const { req } = ctx; + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === undefined) { + if (req.method === 'GET') { + const node = await getNode(client, id); + return node ? Response.json({ node }) : notFound('no such node'); + } + if (req.method === 'DELETE') { + await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + return methodNotAllowed(); + } + + if (req.method !== 'POST') return methodNotAllowed(); + + if (action === 'rename') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'tags') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings'); + const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim()); + if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings'); + // Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a + // useless "headscale error". Normalizing here means the UI can accept either form. + const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)); + await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'routes') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + + let routes: string[]; + if (Array.isArray(body.routes)) { + // Whole-set write: the caller states the complete approved list. + routes = body.routes.filter((r): r is string => typeof r === 'string'); + } else if (typeof body.route === 'string' && typeof body.approved === 'boolean') { + // Single-toggle: read the current set, apply one change, write it back. + const current = await getNode(client, id); + if (!current) return notFound('no such node'); + const set = new Set(current.approvedRoutes); + if (body.approved) set.add(body.route); + else set.delete(body.route); + routes = [...set]; + } else { + return badRequest('expected {routes: string[]} or {route: string, approved: boolean}'); + } + + await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'user') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + // Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated + // by shape and passed through as a string rather than parsed. + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric'); + // Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and + // the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking. + await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'expire') { + // Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered. + await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' }); + return Response.json({ node: await getNode(client, id) }); + } + + return notFound(); +} + +/** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */ +export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + return listNodes(ctx); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + // Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision. + if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric'); + + return handleNodeAction({ ctx, id, action: rest[1] }); +} diff --git a/sidecar/normalize.ts b/sidecar/normalize.ts new file mode 100644 index 0000000..fc96f8a --- /dev/null +++ b/sidecar/normalize.ts @@ -0,0 +1,187 @@ +// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there. +// +// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize +// here so nothing downstream has to know: +// +// 1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks +// silently above 2^53 and Headscale's ids are database-assigned, not small by contract. +// 2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than +// omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null. +// 3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no +// way to distinguish "unset" from "empty", so every accessor tolerates both. + +/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */ +const ZERO_TIME = '0001-01-01T00:00:00Z'; + +/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */ +export function isoOrNull(raw: unknown): string | null { + if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null; + const ms = Date.parse(raw); + if (Number.isNaN(ms)) return null; + // Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too. + return ms < 31_536_000_000 ? null : new Date(ms).toISOString(); +} + +const str = (raw: unknown): string => (typeof raw === 'string' ? raw : ''); +const strArray = (raw: unknown): string[] => + Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []; + +export type UpstreamUser = Record; +export type UpstreamNode = Record; +export type UpstreamPreAuthKey = Record; + +export type OfficerUser = { + id: string; + name: string; + displayName: string | null; + email: string | null; + /** The OIDC provider, when the user came from one. Null for CLI/API-created users. */ + provider: string | null; + profilePicUrl: string | null; + createdAt: string | null; +}; + +export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null { + if (!raw || typeof raw !== 'object') return null; + const id = str(raw.id); + if (!id) return null; + return { + id, + name: str(raw.name), + displayName: str(raw.displayName) || null, + email: str(raw.email) || null, + provider: str(raw.provider) || null, + profilePicUrl: str(raw.profilePicUrl) || null, + createdAt: isoOrNull(raw.createdAt), + }; +} + +export type OfficerNode = { + id: string; + /** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */ + name: string; + hostname: string; + user: OfficerUser | null; + ipAddresses: string[]; + online: boolean; + lastSeen: string | null; + /** When the node's key expires and it must re-authenticate. Null means it never expires. */ + expiry: string | null; + createdAt: string | null; + /** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */ + registerMethod: string; + tags: string[]; + /** Routes the node advertises. */ + availableRoutes: string[]; + /** The subset the admin has approved — the writable one. */ + approvedRoutes: string[]; + /** Routes actually in effect (approved ∩ available, as Headscale computes it). */ + subnetRoutes: string[]; + /** True when the node advertises an exit node route. Purely derived, for the UI's badge. */ + isExitNode: boolean; +}; + +const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']); + +const REGISTER_METHODS: Record = { + REGISTER_METHOD_AUTH_KEY: 'authkey', + REGISTER_METHOD_CLI: 'cli', + REGISTER_METHOD_OIDC: 'oidc', +}; + +export function toNode(raw: UpstreamNode): OfficerNode { + const givenName = str(raw.givenName); + const hostname = str(raw.name); + const availableRoutes = strArray(raw.availableRoutes); + return { + id: str(raw.id), + name: givenName || hostname, + hostname, + user: toUser(raw.user as UpstreamUser), + ipAddresses: strArray(raw.ipAddresses), + online: raw.online === true, + lastSeen: isoOrNull(raw.lastSeen), + expiry: isoOrNull(raw.expiry), + createdAt: isoOrNull(raw.createdAt), + registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown', + tags: strArray(raw.tags), + availableRoutes, + approvedRoutes: strArray(raw.approvedRoutes), + subnetRoutes: strArray(raw.subnetRoutes), + isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)), + }; +} + +export type OfficerPreAuthKey = { + id: string; + /** + * The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally, + * so a secret can never reach the browser except at the moment it is created and must be shown once. + */ + key: string | null; + /** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */ + keyDisplay: string; + user: OfficerUser | null; + reusable: boolean; + ephemeral: boolean; + used: boolean; + expiration: string | null; + createdAt: string | null; + aclTags: string[]; + /** Derived lifecycle, so every surface agrees on what "spent" means. */ + status: 'active' | 'used' | 'expired'; +}; + +/** + * A display label that is never a usable secret. + * + * Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth--***`. + * But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those + * in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about + * hiding them. So a list response on a server with history in it really does contain live secrets. We mask + * anything that isn't already masked rather than trusting the upstream to have done it. + */ +function displayLabel(key: string): string { + if (!key) return '(no key)'; + if (key.endsWith('***')) return key; + return `${key.slice(0, 6)}…-***`; +} + +type ToPreAuthKeyOptions = { + /** + * True only on the creation response, where the secret is the entire point and exists nowhere else. + * Everywhere else this is false and the secret is dropped before it can reach a cache or a browser. + */ + reveal: boolean; +}; + +export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey { + const expiration = isoOrNull(raw.expiration); + const reusable = raw.reusable === true; + const used = raw.used === true; + const key = str(raw.key); + + // A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent. + const expired = !!expiration && Date.parse(expiration) < Date.now(); + const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active'; + + return { + id: str(raw.id), + key: reveal ? key || null : null, + keyDisplay: displayLabel(key), + user: toUser(raw.user as UpstreamUser), + reusable, + ephemeral: raw.ephemeral === true, + used, + expiration, + createdAt: isoOrNull(raw.createdAt), + aclTags: strArray(raw.aclTags), + status, + }; +} + +/** Read an array field out of a gateway response, tolerating the null/absent forms. */ +export function arrayField(body: unknown, field: string): Record[] { + const value = (body as Record | null)?.[field]; + return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record[]) : []; +} diff --git a/sidecar/policy.ts b/sidecar/policy.ts new file mode 100644 index 0000000..8a5a3bd --- /dev/null +++ b/sidecar/policy.ts @@ -0,0 +1,115 @@ +import type { OfficerContext } from './routes'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { HeadscaleError } from './client'; +import { activeClient } from './active'; +import { handlePolicyAssistRoute } from './assist'; + +// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is +// the highest-consequence thing this app can write and the only place a typo silently partitions a network. +// +// Three upstream behaviours drive the shape of this file. +// +// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`) +// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite +// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified +// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint +// that reports it either. So this route makes no claim about writability up front; the first save is +// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner. +// +// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the +// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a +// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here +// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would +// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim. +// +// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes +// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts. + +/** + * Does this failure mean "writing is turned off here", as opposed to "your document is wrong"? + * + * Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive + * costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell + * someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there. + */ +function isWriteDisabled(detail: string): boolean { + const text = detail.toLowerCase(); + if (text.includes('disabled')) return true; + return text.includes('file') && (text.includes('policy') || text.includes('mode')); +} + +type PolicyBody = { policy?: unknown; updatedAt?: unknown }; + +const asText = (value: unknown) => (typeof value === 'string' ? value : ''); +const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null); + +/** + * `GET /_officer/policy`. + * + * Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh + * Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner + * needs to start typing into. Only an unreachable server is an error, because only that leaves nothing + * to say. Note there is no `mode` here on purpose: see the header. + */ +async function getPolicy(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + try { + const body = await client.call('/api/v1/policy'); + return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) }); + } catch (err) { + if (!(err instanceof HeadscaleError)) throw err; + const detail = err.detail ?? err.message; + if (err.status === 404 || detail.toLowerCase().includes('not found')) { + return Response.json({ policy: '', updatedAt: null }); + } + throw err; + } +} + +/** + * `PUT /_officer/policy {policy}`. + * + * The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load + * bearing in a hand-maintained ACL, and re-serializing would destroy both. + */ +async function putPolicy(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + if (typeof body.policy !== 'string') return badRequest('policy must be a string'); + // An empty document would be accepted by some Headscale versions and lock every node out of every other + // one. Deleting a policy is not something to do by leaving a textarea blank and pressing save. + if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection'); + + try { + const saved = await client.call('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } }); + // Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save + // never blanks the editor. + return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) }); + } catch (err) { + if (!(err instanceof HeadscaleError)) throw err; + const detail = err.detail ?? err.message; + + if (isWriteDisabled(detail)) { + return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 }); + } + // Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an + // unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the + // message is the one thing that will fix it. + return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 }); + } +} + +/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */ +export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise { + // `/policy/assist` proposes a document; it never writes one. See assist.ts. + if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1)); + if (rest.length > 0) return badRequest('unexpected path'); + if (ctx.req.method === 'GET') return getPolicy(ctx); + if (ctx.req.method === 'PUT') return putPolicy(ctx); + return methodNotAllowed(); +} diff --git a/sidecar/routes.ts b/sidecar/routes.ts new file mode 100644 index 0000000..c939de7 --- /dev/null +++ b/sidecar/routes.ts @@ -0,0 +1,80 @@ +import { HeadscaleError } from './client'; +import { handleServersRoute } from './servers'; +import { handleNodesRoute } from './nodes'; +import { handleUsersRoute } from './users'; +import { handleKeysRoute } from './keys'; +import { handlePolicyRoute } from './policy'; +import { handleEnrollRoute } from './enroll'; +import { handleSshTestRoute } from './ssh'; +import { handleCompanionRoute } from './companion'; + +// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/. +// +// Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings, +// dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth +// keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the +// browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md. + +export type OfficerContext = { req: Request; url: URL; userId: number }; + +/** 400 with a machine-readable reason. */ +export const badRequest = (error: string) => Response.json({ error }, { status: 400 }); +/** 404 for an unknown /_officer/ path or a missing object. */ +export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 }); +/** 405 when the path exists but the verb doesn't. */ +export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 }); + +/** Parse a JSON request body, or null when there isn't one / it isn't an object. */ +export async function readJson(req: Request): Promise | null> { + const body = await req.json().catch(() => null); + return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; +} + +/** + * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404. + * + * The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence + * is the trust signal — a request without it did not come through the platform. + */ +export async function handleOfficerRoute(req: Request, url: URL): Promise { + const officerUser = req.headers.get('X-Officer-User'); + if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); + + const userId = Number(officerUser); + if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User'); + + const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); + if (segments.length === 0) return null; + + const ctx: OfficerContext = { req, url, userId }; + + try { + switch (segments[0]) { + case 'servers': + return await handleServersRoute(ctx, segments.slice(1)); + // The domain routes below all act on the ACTIVE server — see active.ts for why that isn't a param. + case 'nodes': + return await handleNodesRoute(ctx, segments.slice(1)); + case 'users': + return await handleUsersRoute(ctx, segments.slice(1)); + case 'keys': + return await handleKeysRoute(ctx, segments.slice(1)); + case 'policy': + return await handlePolicyRoute(ctx, segments.slice(1)); + case 'enroll': + return await handleEnrollRoute(ctx, segments.slice(1)); + // Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts. + case 'ssh-test': + return await handleSshTestRoute(ctx, segments.slice(1)); + // The active server's Officer Companion: container health, logs and lifecycle. See companion.ts. + case 'companion': + return await handleCompanionRoute(ctx, segments.slice(1)); + default: + return null; + } + } catch (err) { + // Upstream failures carry their own status; everything else is ours and is a 500 the caller logs. + if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status }); + throw err; + } +} diff --git a/sidecar/servers.ts b/sidecar/servers.ts new file mode 100644 index 0000000..6d9c780 --- /dev/null +++ b/sidecar/servers.ts @@ -0,0 +1,201 @@ +import type { OfficerContext } from './routes'; +import { + listHeadscaleServers, + createHeadscaleServer, + updateHeadscaleServer, + setActiveHeadscaleServer, + deleteHeadscaleServer, + getHeadscaleCredentials, + recordHeadscaleProbe, +} from '../db/queries'; +import { createClient, HeadscaleError } from './client'; +import { probeVersion, MIN_VERSION_LABEL } from './version'; +import { badRequest, notFound, methodNotAllowed } from './routes'; +import { normalizeSshHost } from './ssh'; + +// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner +// registers each with a URL and an admin API key generated on that server, and one is active at a time. +// +// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only +// discovered later as a confusing failure on some unrelated screen: +// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor +// 2. an authenticated call — proves the key actually works +// Neither step is skippable, and a rejected registration is never written. + +/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */ +function normalizeUrl(raw: unknown): string | null { + if (typeof raw !== 'string' || !raw.trim()) return null; + let candidate = raw.trim(); + // Bare host/port is the most common paste; assume https rather than rejecting it. + if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + // Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL. + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; +} + +function requireString(value: unknown, field: string): string | Response { + if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`); + return value.trim(); +} + +/** + * Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against. + * Returns the observed version on success, or a ready-to-send error Response. + */ +async function validateServer(url: string, apiKey: string): Promise { + const probe = await probeVersion(url); + if (!probe.ok) return badRequest(probe.error); + if (probe.supported === false) { + return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`); + } + + // Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own + // clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key'). + const client = createClient({ id: 0, name: 'probe', url, apiKey }); + try { + await client.call('/api/v1/apikey'); + } catch (err) { + if (err instanceof HeadscaleError) { + return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message); + } + throw err; + } + return probe.version; +} + +async function handleCollection(ctx: OfficerContext): Promise { + const { req, userId } = ctx; + + if (req.method === 'GET') { + return Response.json({ servers: await listHeadscaleServers(userId) }); + } + + if (req.method === 'POST') { + const body = (await req.json().catch(() => null)) as Record | null; + if (!body) return badRequest('expected a JSON body'); + + const url = normalizeUrl(body.url); + if (!url) return badRequest('url must be a valid http(s) URL'); + const apiKey = requireString(body.apiKey, 'apiKey'); + if (apiKey instanceof Response) return apiKey; + // The name is a label only; default it to the host so registration needs just a URL and a key. + const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host; + // Optional, and never validated by connecting: registration should not fail because a box is rebooting. + const sshHost = normalizeSshHost(body.sshHost); + if (sshHost instanceof Response) return sshHost; + + const validated = await validateServer(url, apiKey); + if (validated instanceof Response) return validated; + + // First registration becomes active, so the owner is never left with servers but none selected. + const existing = await listHeadscaleServers(userId); + const server = await createHeadscaleServer({ + userId, + name, + url, + apiKey, + version: validated, + sshHost, + activate: existing.length === 0, + }); + return Response.json({ server }, { status: 201 }); + } + + return methodNotAllowed(); +} + +async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise { + const { req, userId } = ctx; + + if (action === 'activate') { + if (req.method !== 'POST') return methodNotAllowed(); + const server = await setActiveHeadscaleServer(userId, id); + return server ? Response.json({ server }) : notFound('no such server'); + } + + if (action === 'health') { + if (req.method !== 'GET') return methodNotAllowed(); + const creds = await getHeadscaleCredentials(userId, id); + if (!creds) return notFound('no such server'); + + const started = Date.now(); + const probe = await probeVersion(creds.url); + if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started }); + + // Reachable — confirm the key too, so "healthy" means "we can actually use this server". + try { + await createClient(creds).call('/api/v1/apikey'); + } catch (err) { + const message = err instanceof HeadscaleError ? err.message : 'upstream error'; + return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started }); + } + + await recordHeadscaleProbe(userId, id, probe.version); + return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started }); + } + + if (action !== undefined) return notFound(); + + if (req.method === 'PATCH') { + const body = (await req.json().catch(() => null)) as Record | null; + if (!body) return badRequest('expected a JSON body'); + + const current = await getHeadscaleCredentials(userId, id); + if (!current) return notFound('no such server'); + + let url: string | undefined; + if (body.url !== undefined) { + const normalized = normalizeUrl(body.url); + if (!normalized) return badRequest('url must be a valid http(s) URL'); + url = normalized; + } + let apiKey: string | undefined; + if (body.apiKey !== undefined) { + const parsed = requireString(body.apiKey, 'apiKey'); + if (parsed instanceof Response) return parsed; + apiKey = parsed; + } + const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined; + // Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null. + let sshHost: string | null | undefined; + if (body.sshHost !== undefined) { + const parsed = normalizeSshHost(body.sshHost); + if (parsed instanceof Response) return parsed; + sshHost = parsed; + } + + // Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact + // state registration works hard to prevent, and an edit can reintroduce it. + if (url !== undefined || apiKey !== undefined) { + const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey); + if (validated instanceof Response) return validated; + } + + const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost }); + return server ? Response.json({ server }) : notFound('no such server'); + } + + if (req.method === 'DELETE') { + const deleted = await deleteHeadscaleServer(userId, id); + return deleted ? new Response(null, { status: 204 }) : notFound('no such server'); + } + + return methodNotAllowed(); +} + +/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */ +export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) return handleCollection(ctx); + + const id = Number(rest[0]); + if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer'); + if (rest.length > 2) return notFound(); + + return handleOne(ctx, id, rest[1]); +} diff --git a/sidecar/ssh.ts b/sidecar/ssh.ts new file mode 100644 index 0000000..7765a7d --- /dev/null +++ b/sidecar/ssh.ts @@ -0,0 +1,86 @@ +import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes'; + +// SSH console support — the escape hatch for when the Headscale API cannot answer. +// +// Officer never handles a password, a key or a port here. The console runs `ssh ` in the owner's own +// shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it. +// That is why this file has no credential handling at all, and why it must never grow any: the moment Officer +// starts holding a private key or a password, this stops being "run the command you would have run yourself". +// +// The host string is typed into an interactive shell, so it is validated to a conservative charset rather than +// quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem; +// rejecting it says which character is wrong while the form is still open. + +/** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */ +const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/; + +/** + * Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"), + * or an error Response. + */ +export function normalizeSshHost(raw: unknown): string | null | Response { + if (raw === null) return null; + if (typeof raw !== 'string') return badRequest('sshHost must be a string'); + const host = raw.trim(); + if (!host) return null; + if (host.length > 255) return badRequest('sshHost is too long'); + if (!SSH_HOST_RE.test(host)) { + return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces'); + } + return host; +} + +type SshProbe = { ok: boolean; error?: string; ms: number }; + +/** + * Prove the machine is reachable with the keys already on this box, without opening a session. + * + * `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password + * or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host + * key here rather than leaving the console to open on an interactive "are you sure" prompt the first time — + * it still refuses a CHANGED key, which is the check worth keeping. + */ +export async function probeSsh(host: string): Promise { + const started = Date.now(); + const proc = Bun.spawn( + ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'], + { stdout: 'ignore', stderr: 'pipe' }, + ); + + // ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever. + const timer = setTimeout(() => proc.kill(), 15_000); + let stderr = ''; + try { + [stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + } finally { + clearTimeout(timer); + } + + const ms = Date.now() - started; + if (proc.exitCode === 0) return { ok: true, ms }; + + // ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise. + const first = stderr + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('Warning: Permanently added')); + return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms }; +} + +/** + * `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form + * needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap. + */ +export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length > 0) return badRequest('unexpected path'); + if (ctx.req.method !== 'POST') return methodNotAllowed(); + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + + const host = normalizeSshHost(body.host); + if (host instanceof Response) return host; + if (!host) return badRequest('host is required'); + + return Response.json(await probeSsh(host)); +} diff --git a/sidecar/users.ts b/sidecar/users.ts new file mode 100644 index 0000000..95a9a52 --- /dev/null +++ b/sidecar/users.ts @@ -0,0 +1,104 @@ +import type { OfficerContext } from './routes'; +import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; +import { activeClient } from './active'; +import { toUser, toNode, arrayField, type OfficerUser } from './normalize'; + +// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys. +// +// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its +// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a +// surprise. That is one extra upstream call for the whole list, not one per user. + +export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number }; + +async function listUsers(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); + + const nodes = arrayField(nodeBody, 'nodes').map(toNode); + const counts = new Map(); + for (const node of nodes) { + const id = node.user?.id; + if (!id) continue; + const entry = counts.get(id) ?? { total: 0, online: 0 }; + entry.total += 1; + if (node.online) entry.online += 1; + counts.set(id, entry); + } + + const users: UserWithCounts[] = arrayField(userBody, 'users') + .map(toUser) + .filter((u): u is OfficerUser => !!u) + .map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return Response.json({ users }); +} + +async function createUser(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + + const created = await client.call<{ user?: Record }>('/api/v1/user', { + method: 'POST', + body: { + name, + displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined, + email: typeof body.email === 'string' ? body.email.trim() : undefined, + }, + }); + return Response.json({ user: toUser(created.user) }, { status: 201 }); +} + +type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleUserAction({ ctx, id, action }: UserActionParams): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === 'rename') { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + // Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident. + const renamed = await client.call<{ user?: Record }>( + `/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, + { method: 'POST' }, + ); + return Response.json({ user: toUser(renamed.user) }); + } + + if (action !== undefined) return notFound(); + + if (ctx.req.method === 'DELETE') { + // Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim. + await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + + return methodNotAllowed(); +} + +/** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */ +export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method === 'GET') return listUsers(ctx); + if (ctx.req.method === 'POST') return createUser(ctx); + return methodNotAllowed(); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric'); + + return handleUserAction({ ctx, id, action: rest[1] }); +} diff --git a/sidecar/version.ts b/sidecar/version.ts new file mode 100644 index 0000000..cd90215 --- /dev/null +++ b/sidecar/version.ts @@ -0,0 +1,79 @@ +// Headscale version detection and the supported floor. +// +// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API +// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at +// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth +// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.23–0.28 would mean +// carrying several incompatible data models; refusing them at registration time costs one probe. +// +// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at +// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three +// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and +// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all. + +export const MIN_MAJOR = 0; +export const MIN_MINOR = 29; +export const MIN_VERSION_LABEL = '0.29'; + +const PROBE_TIMEOUT_MS = 8000; + +export type VersionProbe = + | { ok: true; version: string; supported: true } + /** Reached the server but can't judge the version — self-built images report the literal 'dev'. */ + | { ok: true; version: string; supported: 'unknown' } + | { ok: true; version: string; supported: false } + | { ok: false; error: string }; + +/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */ +export function parseVersion(raw: string): { major: number; minor: number } | null { + const m = raw + .trim() + .replace(/^v/, '') + .match(/^(\d+)\.(\d+)/); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]) }; +} + +/** Whether a parsed version is at or above the supported floor. */ +export function meetsFloor(v: { major: number; minor: number }): boolean { + if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR; + return v.minor >= MIN_MINOR; +} + +/** + * Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during + * registration — it tells us "is there a Headscale here at all" before we bother validating a key. + * + * An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without + * VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments. + */ +export async function probeVersion(baseUrl: string): Promise { + let res: Response; + try { + res = await fetch(`${baseUrl}/version`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + } catch { + return { ok: false, error: 'server unreachable' }; + } + + if (!res.ok) { + // A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL + // pointing at a reverse proxy or an unrelated service rather than at Headscale itself. + return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` }; + } + + let version: string; + try { + const body = (await res.json()) as { version?: unknown }; + if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' }; + version = body.version; + } catch { + return { ok: false, error: 'GET /version did not return JSON' }; + } + + const parsed = parseVersion(version); + if (!parsed) return { ok: true, version, supported: 'unknown' }; + return { ok: true, version, supported: meetsFloor(parsed) }; +} diff --git a/web/Cards.tsx b/web/Cards.tsx new file mode 100644 index 0000000..a9f9fe5 --- /dev/null +++ b/web/Cards.tsx @@ -0,0 +1,103 @@ +import type { ReactNode } from 'react'; + +// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black +// cards on hairline white borders. Kept local to the app so the look changes in one place. + +export const Card = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export const SectionHeader = ({ + title, + subtitle, + action, +}: { + title: string; + subtitle?: string; + action?: ReactNode; +}) => ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
} +
+); + +type ButtonProps = { + children: ReactNode; + onClick?: () => void; + type?: 'button' | 'submit'; + variant?: 'primary' | 'ghost' | 'danger'; + disabled?: boolean; + title?: string; +}; + +const VARIANTS: Record, string> = { + primary: 'border-primary/40 bg-primary/15 text-primary hover:bg-primary/25', + ghost: 'border-white/10 bg-white/[0.02] text-zinc-300 hover:bg-white/10 hover:text-zinc-100', + danger: 'border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20', +}; + +export const Button = ({ children, onClick, type = 'button', variant = 'ghost', disabled, title }: ButtonProps) => ( + +); + +type FieldProps = { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + hint?: string; + type?: 'text' | 'password'; + autoFocus?: boolean; +}; + +export const Field = ({ label, value, onChange, placeholder, hint, type = 'text', autoFocus }: FieldProps) => ( + +); + +/** Tiny status light: green healthy, amber unknown/unverified, red failing. */ +export const Dot = ({ tone }: { tone: 'ok' | 'warn' | 'bad' | 'idle' }) => { + const color = + tone === 'ok' ? 'bg-emerald-400' : tone === 'warn' ? 'bg-amber-400' : tone === 'bad' ? 'bg-red-400' : 'bg-zinc-600'; + return ; +}; + +export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => ( + + {children} + +); + +export const ErrorNote = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); diff --git a/web/ConsoleView.tsx b/web/ConsoleView.tsx new file mode 100644 index 0000000..fe932b9 --- /dev/null +++ b/web/ConsoleView.tsx @@ -0,0 +1,96 @@ +import { useCallback } from 'react'; +import { Link } from 'react-router'; +import { Loader2, TerminalSquare } from 'lucide-react'; +import { headscaleSectionPath } from './shared'; +import { useHeadscaleServers } from './useHeadscaleServers'; +import { TerminalView } from 'officerdev'; +import { Button } from './Cards'; + +// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot +// answer (why headscale won't start, what the logs say, whether the disk is full). +// +// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh ` typed +// into a login shell. Officer holds no key, no password and no port: whatever `ssh` on this box can already +// reach, this can reach, and nothing more. If the connection needs a jump host or an odd port, that belongs in +// `~/.ssh/config` as a Host alias — which this field accepts by name. +// +// The session id is derived from the server id rather than minted per panel, so re-opening the Console lands +// back in the shell that is already running and mid-command, and switching servers is a different shell rather +// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer, +// which is what stops a re-attach from typing a second `ssh` inside the first. + +const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+
+ +
+ {children} +
+); + +export const ConsoleView = () => { + const { active, isLoading } = useHeadscaleServers(); + + // The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants + // a stable callback and an inline arrow would remount its effect on every render. + const onConnectionChange = useCallback(() => {}, []); + + if (isLoading) { + return ( +
+ + Loading servers… +
+ ); + } + + if (!active) { + return ( + +
+
No server selected
+

Pick one in the Servers section to open its console.

+
+
+ ); + } + + if (!active.sshHost) { + return ( + +
+
No SSH address for {active.name}
+

+ Add one on the server to open a shell on the machine behind it. Use the machine's own address rather than + the Headscale hostname — the console is most useful exactly when that name has stopped answering. +

+
+ + + +
+ ); + } + + return ( +
+
+ + + ssh {active.sshHost} · {active.name} + +
+ +
+ ); +}; diff --git a/web/DiagnosticsView.tsx b/web/DiagnosticsView.tsx new file mode 100644 index 0000000..9595d8c --- /dev/null +++ b/web/DiagnosticsView.tsx @@ -0,0 +1,326 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Activity, + AlertTriangle, + Loader2, + Play, + PlugZap, + RotateCw, + ScrollText, + Square, + Trash2, + Unplug, +} from 'lucide-react'; +import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared'; +import { timeAgo } from './format'; +import { useHeadscaleServers } from './useHeadscaleServers'; +import { + useCompanionAction, + useCompanionHealth, + useCompanionLogStream, + useCompanionLogs, +} from './useHeadscaleCompanion'; +import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards'; +import { ViewShell } from './ViewShell'; + +// What the admin API structurally cannot tell you: is the container running, what did it log on the way +// down, and can we bring it back. All of it comes from the Officer Companion deployed alongside the server. +// +// The companion is optional and per-server, so "not deployed" is the ordinary case for a server that has +// never had one, and is rendered as an explanation rather than an error. Note the two inversions this +// section is built around: the companion's /health is ALWAYS HTTP 200 (read `verdict`, never the status), +// and an unavailable companion says nothing about Headscale itself — the admin API on the same domain is +// independent and the other sections may be working perfectly. + +const VERDICTS: Record = { + ok: { tone: 'ok', label: 'Healthy', blurb: 'The container is running and Headscale is answering.' }, + degraded: { + tone: 'warn', + label: 'Degraded', + blurb: 'The container is running, but Headscale is not answering properly.', + }, + down: { tone: 'bad', label: 'Down', blurb: 'The container is not running.' }, + unknown: { tone: 'idle', label: 'Unknown', blurb: 'Docker does not know this container.' }, +}; + +const ACTIONS: { id: CompanionAction; label: string; icon: typeof RotateCw; confirm: string }[] = [ + { + id: 'restart', + label: 'Restart', + icon: RotateCw, + confirm: 'Restart the Headscale container? Every node loses its control-plane connection until it is back.', + }, + { + id: 'stop', + label: 'Stop', + icon: Square, + confirm: 'Stop the Headscale container? Every node stays disconnected until you start it again.', + }, + { id: 'start', label: 'Start', icon: Play, confirm: 'Start the Headscale container?' }, +]; + +/** The RFC3339 zero date the companion passes through from docker for a container that never finished. */ +const isZeroDate = (iso: string) => iso.startsWith('0001-'); + +const Row = ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+ {label} + {value} +
+); + +const ContainerFacts = ({ container }: { container: CompanionContainer }) => ( +
+ + + + {!container.running && !isZeroDate(container.finishedAt) && ( + + )} + +
+); + +/** Evidence, shown only when the verdict is not ok — likely causes first, then the raw tail behind them. */ +const Evidence = ({ health }: { health: CompanionHealthBody }) => { + const causes = health.likelyCauses ?? []; + const recent = health.recentLogs ?? []; + if (causes.length === 0 && recent.length === 0 && !health.healthcheckOutput) return null; + + return ( +
+ {causes.length > 0 && ( +
+
+ + Likely causes +
+
    + {causes.map((cause) => ( +
  • + {cause} +
  • + ))} +
+ {/* The companion reads these out of the logs heuristically. Saying so is the difference between a + hint the owner checks and a diagnosis they trust and then chase down the wrong hole. */} +

Guessed from the logs — treat them as leads, not answers.

+
+ )} + + {health.healthcheckOutput && ( +
+
Healthcheck output
+
+            {health.healthcheckOutput}
+          
+
+ )} + + {recent.length > 0 && ( +
+
Last lines before now
+
+            {recent.join('\n')}
+          
+
+ )} +
+ ); +}; + +const TAILS = [100, 500, 2000]; + +const LogViewer = () => { + const [tail, setTail] = useState(200); + const [follow, setFollow] = useState(false); + const snapshot = useCompanionLogs(tail, !follow); + const stream = useCompanionLogStream(follow, tail); + const boxRef = useRef(null); + + const lines = follow ? stream.lines : snapshot.data?.available ? snapshot.data.lines : []; + + // Pin to the bottom while following. Only while following: scrolling a snapshot back to the top and having + // it yanked down again would be the viewer fighting the reader. + useEffect(() => { + if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; + }, [follow, lines.length]); + + const unavailable = !follow && snapshot.data && !snapshot.data.available ? snapshot.data.reason : null; + + return ( + +
+
+ + Logs + {follow && ( + + + {stream.live ? 'live' : 'stopped'} + + )} +
+ +
+ {TAILS.map((n) => ( + + ))} +
+ + {follow ? ( + + ) : ( + + )} +
+ + {stream.error && ( +
{stream.error}
+ )} + {unavailable &&
{unavailable}
} + +
+        {lines.length > 0
+          ? lines.join('\n')
+          : snapshot.isLoading
+            ? 'Loading…'
+            : follow
+              ? 'Waiting for output…'
+              : 'No log lines.'}
+      
+
+ ); +}; + +const Lifecycle = ({ running }: { running: boolean | null }) => { + const action = useCompanionAction(); + const [pending, setPending] = useState(null); + + const run = async (id: CompanionAction, confirm: string) => { + if (!window.confirm(confirm)) return; + setPending(id); + try { + await action.mutateAsync(id); + } catch { + /* surfaced from action.error below */ + } finally { + setPending(null); + } + }; + + const result = action.data; + + return ( +
+
+ {ACTIONS.map(({ id, label, icon: Icon, confirm }) => ( + + ))} + Acts on the container, not on Officer. +
+ + {action.error != null && The action could not be sent.} + {result && !result.available && {result.reason}} + {result && result.available && !result.ok && ( + {result.error ?? 'Docker refused the action.'} + )} + {result && result.available && result.ok && ( +
+ {result.action}: {result.result} +
+ )} +
+ ); +}; + +export const DiagnosticsView = () => { + const { active } = useHeadscaleServers(); + const query = useCompanionHealth(); + const result = query.data; + + return ( + +
+ : undefined} + /> + + {result && !result.available ? ( + +
+
+ + No companion on this server +
+

+ {result.reason}. The Officer Companion is a small service deployed next to Headscale that can see its + container — it is what makes health, logs and restart possible from here. +

+

+ This says nothing about Headscale itself: it is served by the same domain but a different process, so + the other sections may be working normally. When the companion is missing, the Console section is the + way in. +

+
+
+ ) : result ? ( + <> + +
+
+ +
+
+
{VERDICTS[result.health.verdict].label}
+

+ {result.health.reason ?? VERDICTS[result.health.verdict].blurb} +

+
+
+ +
+ + {result.health.probe && } + {result.health.container && } +
+ + + +
+ + + + ) : null} +
+
+ ); +}; diff --git a/web/HeadscaleNav.tsx b/web/HeadscaleNav.tsx new file mode 100644 index 0000000..2e14b31 --- /dev/null +++ b/web/HeadscaleNav.tsx @@ -0,0 +1,75 @@ +import type { LucideIcon } from 'lucide-react'; +import { NavLink } from 'react-router'; +import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react'; +import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared'; +import { useHeadscaleServers } from './useHeadscaleServers'; + +// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel +// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate. +// +// Sections are real links to /headscale/
, not channel writes — so they cmd-click into a new tab, +// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than +// being derived in JS, per the navigation audit's Phase 4. + +const ICONS: Record = { + servers: Server, + nodes: Laptop, + users: Users, + keys: KeyRound, + invites: Smartphone, + policy: ShieldCheck, + diagnostics: Activity, + console: TerminalSquare, +}; + +const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors'; + +type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean }; + +const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => ( + <> + {selected && } + + {label} + +); + +export const HeadscaleNav = () => { + const { active } = useHeadscaleServers(); + + return ( + + ); +}; diff --git a/web/HeadscaleServerPicker.tsx b/web/HeadscaleServerPicker.tsx new file mode 100644 index 0000000..9ab54c9 --- /dev/null +++ b/web/HeadscaleServerPicker.tsx @@ -0,0 +1,60 @@ +import { Check, Network, Plus } from 'lucide-react'; +import { Link } from 'react-router'; +import { headscaleSectionPath } from './shared'; +import { useHeadscaleServers } from './useHeadscaleServers'; + +// Top-left panel of the /headscale workspace: which server everything else acts on. +// +// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions — +// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation +// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the +// section list below is real links. +// +// Every registered server is listed, including when there is only one: the panel's whole job is to say what +// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible. + +export const HeadscaleServerPicker = () => { + const { servers, active, activate, isLoading } = useHeadscaleServers(); + + return ( +
+
+
+ +
+
+
Headscale
+
{active ? active.name : 'no server'}
+
+
+ +
+ {servers.map((server) => ( + + ))} + + {servers.length === 0 && !isLoading && ( + + + Register a server + + )} +
+
+ ); +}; diff --git a/web/HeadscaleView.tsx b/web/HeadscaleView.tsx new file mode 100644 index 0000000..f1d35bf --- /dev/null +++ b/web/HeadscaleView.tsx @@ -0,0 +1,37 @@ +import { useHeadscaleSection } from './useHeadscaleSection'; +import { ServersView } from './ServersView'; +import { NodesView } from './NodesView'; +import { UsersView } from './UsersView'; +import { KeysView } from './KeysView'; +import { InvitesView } from './InvitesView'; +import { PolicyView } from './PolicyView'; +import { DiagnosticsView } from './DiagnosticsView'; +import { ConsoleView } from './ConsoleView'; + +// Right panel of the /headscale workspace — renders the section named by the URL. +// +// Every section except `servers` acts on whichever server is active; each handles the "none selected" case +// itself through ViewShell, so there is no gating to do here. + +export const HeadscaleView = () => { + const section = useHeadscaleSection(); + + switch (section) { + case 'nodes': + return ; + case 'users': + return ; + case 'keys': + return ; + case 'invites': + return ; + case 'policy': + return ; + case 'diagnostics': + return ; + case 'console': + return ; + default: + return ; + } +}; diff --git a/web/HeadscaleViewHeader.tsx b/web/HeadscaleViewHeader.tsx new file mode 100644 index 0000000..0a03c19 --- /dev/null +++ b/web/HeadscaleViewHeader.tsx @@ -0,0 +1,23 @@ +import { Network } from 'lucide-react'; +import { useHeadscaleServers } from './useHeadscaleServers'; +import { HEADSCALE_SECTIONS } from './shared'; +import { useHeadscaleSection } from './useHeadscaleSection'; + +// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is +// acting on — with several registered, "delete this node" is only safe if the target is unambiguous. + +export const HeadscaleViewHeader = () => { + const section = useHeadscaleSection(); + const { active } = useHeadscaleServers(); + const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale'; + + return ( + <> + + + {label} + {active && · {active.name}} + + + ); +}; diff --git a/web/InvitesView.tsx b/web/InvitesView.tsx new file mode 100644 index 0000000..81ca3f5 --- /dev/null +++ b/web/InvitesView.tsx @@ -0,0 +1,394 @@ +import { useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; +import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react'; +import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared'; +import { INVITE_TTL_DEFAULT_SECONDS } from './shared'; +import { useHeadscaleInvites } from './useHeadscaleInvites'; +import { useHeadscaleUsers } from './useHeadscaleData'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { fullDate, timeAgo, timeUntil } from './format'; +import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards'; +import { EmptyBody, ViewShell } from './ViewShell'; +import { copyToClipboard } from 'helpers/clipboard'; + +// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5. +// +// The point of the feature is that the person joining does nothing but tap a link and press Join: no app +// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin +// does all of it here and sends one link. +// +// TWO RULES SHAPE THIS FILE. +// +// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display, +// log or store it beyond the moment it is handed to the admin. So the created invite lives in component +// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and +// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another. +// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted +// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole +// reason the credential is not in the URL. + +const STATUS_TONE: Record = { + pending: 'warn', + claimed: 'ok', + expired: 'idle', + revoked: 'bad', +}; + +/** Presets rather than a free number: every one is inside the spec's 60s–24h range by construction. */ +const TTL_OPTIONS = [ + { seconds: 300, label: '5 minutes' }, + { seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' }, + { seconds: 3600, label: '1 hour' }, + { seconds: 86_400, label: '24 hours' }, +] as const; + +const CopyButton = ({ value, label }: { value: string; label: string }) => { + const [done, setDone] = useState(false); + const copy = () => { + void copyToClipboard(value); + setDone(true); + window.setTimeout(() => setDone(false), 1500); + }; + return ( + + ); +}; + +/** + * The QR, rendered client-side into a canvas. + * + * It never leaves the browser — an image endpoint would put the claim token in a request line and therefore + * in a server log, which is the exact thing the fragment-only link format exists to prevent. Error + * correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not + * printed and posted. + */ +const InviteQr = ({ url }: { url: string }) => { + const canvas = useRef(null); + + useEffect(() => { + if (!canvas.current) return; + void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 }); + }, [url]); + + return ; +}; + +type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void }; + +const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { + const [showQr, setShowQr] = useState(true); + + // Plain https now — the link lands on a page the companion serves, which bounces into the app. It goes in + // `url` rather than `text` so share targets treat it as a link and preserve the fragment. A cancelled sheet + // rejects — nothing to report there, the link is still on screen. + const share = () => { + void navigator + .share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}`, url: invite.url }) + .catch(() => {}); + }; + + return ( +
+
+ +
+
Send this link to the device
+

+ It opens OffScale, shows one confirmation screen and joins as{' '} + {invite.user}. Single use, and it stops working{' '} + {timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy — dismiss this and it is gone. +

+
+
+ +
+
+ {invite.url} +
+ + {showQr && ( +
+ +
+ )} + +
+ + {/* Only where the OS actually has a share sheet — a button that silently does nothing is worse + than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */} + {typeof navigator.share === 'function' && ( + + )} + + +
+
+
+ ); +}; + +type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void }; + +const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { + const { users } = useHeadscaleUsers(); + const { create } = useHeadscaleInvites(); + const [user, setUser] = useState(''); + const [note, setNote] = useState(''); + const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS); + const [ephemeral, setEphemeral] = useState(false); + const [tags, setTags] = useState(''); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + // The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the + // one this list came from by the time it is claimed. + const chosen = user || users[0]?.name; + if (!chosen) return setError('Create a user first — an invite files the joining device under one.'); + + try { + const invite = await create.mutateAsync({ + user: chosen, + ttlSeconds: ttl, + ephemeral, + note: note.trim(), + tags: tags + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean), + }); + onCreated(invite); + onClose(); + } catch (err) { + setError(headscaleErrorMessage(err)); + } + }; + + return ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
Authorize a new device
+ + + + + + + + + + + + {error && {error}} + +
+ + +
+ +
+ ); +}; + +type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void }; + +const InviteRow = ({ invite, onError }: InviteRowProps) => { + const { revoke } = useHeadscaleInvites(); + const [confirming, setConfirming] = useState(false); + + const run = async () => { + try { + await revoke.mutateAsync(invite.id); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( + +
+ +
+
+ {invite.note || 'Untitled invite'} + {invite.user} + {invite.ephemeral && ephemeral} + {invite.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ {invite.status} + {invite.status === 'pending' && ( + · expires {timeUntil(invite.expiresAt ?? null)} + )} + {invite.status === 'claimed' && ( + + · claimed {timeAgo(invite.claimedAt ?? null)} + {invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''} + + )} + · created {timeAgo(invite.createdAt ?? null)} +
+
+ + {/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in + Nodes, and conflating the two here would make "revoke" mean two different things. */} + {invite.status === 'pending' && ( +
+ {confirming ? ( + <> + + + + ) : ( + + )} +
+ )} +
+
+ ); +}; + +export const InvitesView = () => { + const { invites, unavailable, isLoading, error } = useHeadscaleInvites(); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(null); + const [actionError, setActionError] = useState(null); + + const pending = invites.filter((i) => i.status === 'pending').length; + + return ( + +
+
+
+

Device invites

+

+ {unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`} +

+
+ {!creating && !unavailable && ( + + )} +
+ + {/* Most registered servers have no enrolment API, and that is a normal state — the rest of the + Headscale sections work regardless, so this must not read as a broken screen. */} + {unavailable && ( + } + title="This server cannot mint invites" + hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`} + /> + )} + + {created && setCreated(null)} />} + {creating && setCreating(false)} />} + {actionError && {actionError}} + + {!unavailable && invites.length === 0 && !creating && ( + } + title="No invites yet" + hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure." + /> + )} + + {invites.map((invite) => ( + + ))} +
+
+ ); +}; diff --git a/web/KeysView.tsx b/web/KeysView.tsx new file mode 100644 index 0000000..e3b25d2 --- /dev/null +++ b/web/KeysView.tsx @@ -0,0 +1,341 @@ +import { useState } from 'react'; +import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react'; +import type { HeadscalePreAuthKey } from './shared'; +import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData'; +import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers'; +import { timeAgo, timeUntil, fullDate } from './format'; +import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards'; +import { ViewShell, EmptyBody } from './ViewShell'; +import { copyToClipboard } from 'helpers/clipboard'; + +// Pre-auth keys — the tokens a machine presents to join the tailnet. +// +// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the +// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the +// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy +// and the ready-to-paste join command, and only clears on an explicit dismiss. +// +// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones. + +const STATUS_FILTERS = [ + { id: 'active', label: 'Active' }, + { id: 'all', label: 'All' }, +] as const; + +type StatusFilter = (typeof STATUS_FILTERS)[number]['id']; + +const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const; + +const CopyButton = ({ value, label }: { value: string; label: string }) => { + const [done, setDone] = useState(false); + const copy = () => { + void copyToClipboard(value); + setDone(true); + window.setTimeout(() => setDone(false), 1500); + }; + return ( + + ); +}; + +type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void }; + +const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => { + const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`; + return ( +
+
+ +
+
Copy this key now
+

+ Headscale stores it hashed. Once you dismiss this, nothing — not Officer, not the server — can show it + again. +

+
+
+
+
+
Key
+
+ {secret} +
+
+
+
Join command
+
+ {command} +
+
+
+ + + +
+
+
+ ); +}; + +type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string }; + +const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => ( + +); + +type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void }; + +const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => { + const { users } = useHeadscaleUsers(); + const { create } = useHeadscaleKeys(); + const [userId, setUserId] = useState(''); + const [reusable, setReusable] = useState(false); + const [ephemeral, setEphemeral] = useState(false); + const [days, setDays] = useState('90'); + const [tags, setTags] = useState(''); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + const chosen = userId || users[0]?.id; + if (!chosen) return setError('Create a user first — every key belongs to one.'); + const expirationDays = Number(days); + if (!Number.isFinite(expirationDays) || expirationDays <= 0) + return setError('Expiry must be a positive number of days'); + + try { + const result = await create.mutateAsync({ + userId: chosen, + reusable, + ephemeral, + expirationDays, + aclTags: tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean), + }); + if (result.key.key) onCreated(result.key.key); + onClose(); + } catch (err) { + setError(headscaleErrorMessage(err)); + } + }; + + return ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
New pre-auth key
+ + + +
+ + +
+ + + + + {error && {error}} + +
+ + +
+ +
+ ); +}; + +type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void }; + +const KeyRow = ({ entry, onError }: KeyRowProps) => { + const { expire, remove } = useHeadscaleKeys(); + const [confirming, setConfirming] = useState(false); + const busy = expire.isPending || remove.isPending; + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( + +
+ +
+
+ {entry.keyDisplay} + {entry.user && {entry.user.name}} + {entry.reusable && reusable} + {entry.ephemeral && ephemeral} + {entry.aclTags.map((tag) => ( + + {tag} + + ))} +
+
+ {entry.status} + · expires {timeUntil(entry.expiration)} + · created {timeAgo(entry.createdAt)} +
+
+ +
+ {entry.status === 'active' && ( + + )} + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +export const KeysView = () => { + const { keys, isLoading, error } = useHeadscaleKeys(); + const { active } = useHeadscaleServers(); + const [creating, setCreating] = useState(false); + const [secret, setSecret] = useState(null); + const [filter, setFilter] = useState('active'); + const [actionError, setActionError] = useState(null); + + const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active'); + const activeCount = keys.filter((k) => k.status === 'active').length; + + return ( + +
+
+
+

Pre-auth keys

+

+ {activeCount} active of {keys.length} +

+
+
+
+ {STATUS_FILTERS.map((option) => ( + + ))} +
+ {!creating && ( + + )} +
+
+ + {secret && setSecret(null)} />} + {creating && setCreating(false)} />} + {actionError && {actionError}} + + {keys.length === 0 && !creating && ( + } + title="No pre-auth keys" + hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you." + /> + )} + {keys.length > 0 && visible.length === 0 && ( +
+ No active keys. Switch to “All” to see spent and expired ones. +
+ )} + + {visible.map((entry) => ( + + ))} +
+
+ ); +}; diff --git a/web/NodesView.tsx b/web/NodesView.tsx new file mode 100644 index 0000000..bdbc479 --- /dev/null +++ b/web/NodesView.tsx @@ -0,0 +1,436 @@ +import { useState } from 'react'; +import { + Laptop, + Globe, + Trash2, + Pencil, + TimerReset, + Check, + X, + Search, + Copy, + ChevronRight, + UserRound, + ArrowRightLeft, + Tag as TagIcon, +} from 'lucide-react'; +import type { HeadscaleNode } from './shared'; +import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { timeAgo, timeUntil, fullDate } from './format'; +import { Card, Button, Dot, Badge, ErrorNote } from './Cards'; +import { ViewShell, EmptyBody } from './ViewShell'; +import { copyToClipboard } from 'helpers/clipboard'; + +// The nodes section — the machines in the tailnet. +// +// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node +// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is +// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved +// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets. + +const copy = (text: string) => void copyToClipboard(text); + +type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void }; + +const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => { + const isExit = route === '0.0.0.0/0' || route === '::/0'; + return ( +
+ {isExit ? : } + {route} + {isExit && exit node} + +
+ ); +}; + +/** + * Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the + * editor accepts either form and normalizes here — which is also how the dirty check stays honest, since + * `web` and `tag:web` are the same tag and neither should look like an edit. + */ +const parseTags = (text: string): string[] => { + const parts = text + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean); + return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))]; +}; + +/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */ +const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t)); + +type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void }; + +/** + * Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit + * together behind the disclosure rather than next to Rename. + * + * Mounted only while the card is expanded: it needs the user list, and fetching every user to render a + * collapsed row would be a request per screenful for a control nobody is looking at. The query key is + * shared with the Users section, so an expanded card is usually a cache hit anyway. + */ +const Ownership = ({ node, busy, onError }: OwnershipProps) => { + const { setTags, moveToUser } = useHeadscaleNodes(); + const { users } = useHeadscaleUsers(); + + const [owner, setOwner] = useState(node.user?.id ?? ''); + const [draftTags, setDraftTags] = useState(node.tags.join(' ')); + + const pending = setTags.isPending || moveToUser.isPending; + const nextTags = parseTags(draftTags); + const tagsDirty = !sameTags(nextTags, node.tags); + const ownerDirty = !!owner && owner !== node.user?.id; + const target = users.find((u) => u.id === owner); + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( +
+
Owner and tags
+ +
+ + + {ownerDirty && ( + <> + + + + )} +
+ + {/* Said before the move, not after: the node keeps its address and its tags, but the rules that let + anything reach it are written per user, so it can go dark to everything that used to see it. */} + {ownerDirty && ( +

+ Moving this node to {target?.name ?? 'another user'} changes which policy + rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '} + {node.user?.name ?? 'its current owner'} will stop. +

+ )} + +
+ + setDraftTags(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags })); + if (ev.key === 'Escape') setDraftTags(node.tags.join(' ')); + }} + placeholder="tag:server tag:eu — space separated" + spellCheck={false} + autoComplete="off" + className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50" + /> + {tagsDirty && ( + <> + + + + )} +
+

+ Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on + cuts the node off from it. The tag: prefix is added for you. +

+
+ ); +}; + +type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void }; + +const NodeCard = ({ node, onError }: NodeCardProps) => { + const { rename, toggleRoute, expire, remove } = useHeadscaleNodes(); + const [open, setOpen] = useState(false); + const [renaming, setRenaming] = useState(false); + const [draftName, setDraftName] = useState(node.name); + const [confirming, setConfirming] = useState(false); + + const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending; + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + const submitRename = async () => { + const name = draftName.trim(); + setRenaming(false); + if (!name || name === node.name) return; + await run(() => rename.mutateAsync({ id: node.id, name })); + }; + + return ( + +
+
+ +
+ {renaming ? ( +
+ setDraftName(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void submitRename(); + if (ev.key === 'Escape') setRenaming(false); + }} + autoFocus + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50" + /> + + +
+ ) : ( +
+ + {node.id}: {node.hostname}{' '} + ({node.name}) + + {node.user && {node.user.name}} + {node.isExitNode && exit} + {node.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ {node.ipAddresses[0] ?? 'no address'} + · {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`} + {node.subnetRoutes.length > 0 && · {node.subnetRoutes.length} route(s) active} +
+
+ +
+ + {open && ( +
+
+
Addresses
+
+ {node.ipAddresses.map((ip) => ( + + ))} +
+
Hostname
+
{node.hostname}
+
Registered
+
+ {timeAgo(node.createdAt)} · {node.registerMethod} +
+
Key expires
+
+ {timeUntil(node.expiry)} +
+
Last seen
+
+ {node.online ? 'now' : timeAgo(node.lastSeen)} +
+
+ +
+
+ Advertised routes +
+ {node.availableRoutes.length === 0 ? ( +
This node advertises no routes.
+ ) : ( +
+ {node.availableRoutes.map((route) => ( + void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))} + /> + ))} +
+ )} +
+ + + +
+ + + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+ )} +
+
+ ); +}; + +export const NodesView = () => { + const { nodes, isLoading, error } = useHeadscaleNodes(); + const [filter, setFilter] = useState(''); + const [actionError, setActionError] = useState(null); + + const needle = filter.trim().toLowerCase(); + const visible = needle + ? nodes.filter( + (n) => + n.id === needle || + n.name.toLowerCase().includes(needle) || + n.hostname.toLowerCase().includes(needle) || + n.user?.name.toLowerCase().includes(needle) || + n.ipAddresses.some((ip) => ip.includes(needle)) || + n.tags.some((t) => t.toLowerCase().includes(needle)), + ) + : nodes; + + const online = nodes.filter((n) => n.online).length; + + return ( + +
+
+
+

Nodes

+

+ {nodes.length} registered · {online} online +

+
+
+ + setFilter(ev.target.value)} + placeholder="Filter by id, name, user, IP, tag" + spellCheck={false} + className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50" + /> +
+
+ + {actionError && {actionError}} + + {nodes.length === 0 && ( + } + title="No nodes yet" + hint="Create a pre-auth key and run `tailscale up --login-server --authkey ` on a machine to join it." + /> + )} + {nodes.length > 0 && visible.length === 0 && ( +
Nothing matches “{filter}”.
+ )} + + {visible.map((node) => ( + + ))} +
+
+ ); +}; diff --git a/web/PolicyAssistant.tsx b/web/PolicyAssistant.tsx new file mode 100644 index 0000000..4bfed3f --- /dev/null +++ b/web/PolicyAssistant.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from 'react'; +import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react'; +import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy'; +import { collapseUnchanged, diffCounts, diffLines } from './diff'; +import { Button, Card, ErrorNote } from './Cards'; + +// Ask for a policy change in English; read the diff; decide. +// +// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners, +// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to +// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor +// above and the existing Save button is still the only thing that leaves the browser. +// +// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows +// exactly what accepting would change on screen — including edits the owner made and hasn't saved. + +const EXAMPLES = [ + 'let everyone reach the machines tagged tag:server on port 22', + 'stop the phones from reaching anything except the DNS server', + 'add a group for family with just my own user in it', +]; + +const DiffBody = ({ before, after }: { before: string; after: string }) => { + const lines = useMemo(() => diffLines(before, after), [before, after]); + const rows = useMemo(() => collapseUnchanged(lines), [lines]); + const { added, removed } = useMemo(() => diffCounts(lines), [lines]); + + if (!added && !removed) { + return

No change — the proposal matches what you have.

; + } + + return ( + <> +
+ +{added} + −{removed} + unchanged lines collapsed +
+
+ {rows.map((row, index) => + row === null ? ( +
+ ⋯ +
+ ) : ( +
+ {row.kind === 'add' ? '+' : row.kind === 'remove' ? '−' : ' '} {row.text} +
+ ), + )} +
+ + ); +}; + +type PolicyAssistantProps = { + /** The text on screen right now. Sent up as the base, and diffed against. */ + policy: string; + /** Accepting a proposal — puts it in the editor's draft. Never saves. */ + onApply: (policy: string) => void; + disabled?: boolean; +}; + +export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => { + const assist = useHeadscalePolicyAssist(); + const [prompt, setPrompt] = useState(''); + // The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and + // a second ask doesn't briefly show the previous answer against the new base. + const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null); + + const ask = async () => { + const request = prompt.trim(); + if (!request || assist.isPending) return; + setProposal(null); + try { + setProposal(await assist.mutateAsync({ prompt: request, policy })); + } catch { + // Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice. + } + }; + + const apply = () => { + if (!proposal) return; + onApply(proposal.policy); + setProposal(null); + setPrompt(''); + assist.reset(); + }; + + return ( + +
+ + Describe the change + Proposes a document — never saves it +
+ +
+