From cca85a7524cf2b85c3bbb7dacf737951df461eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 18:14:17 +0000 Subject: [PATCH] offscale moves to its own repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushed to gitea.officer.dev/plugins/offscale (50 files, verified byte-identical to the working copy by cloning it back), then untracked here. Order matters and it was this way round deliberately: until that push, this repository was the only copy of those 49 files. Untracking first would have left them on one disk. The push is what made the removal safe, so it happened first and was verified before anything was removed. The files stay on disk and the installed plugin keeps working — this changes who owns the code, not what is running. `example` stays tracked, alone. It has no repository yet, and it is the reference implementation EXTRACTING-A-PLUGIN.md sends people to read; removing it would both delete it from everywhere but this disk and leave the doc pointing at nothing. It follows once it has a remote. Not done, and worth knowing before a fresh install: plugins/offscale is PRIVATE, and fetch.ts clones anonymously over https with no credentials by design. So offscale cannot be installed from the marketplace the way music can. It needs to be public, or the marketplace needs authentication — which is a feature, not an oversight to patch quietly. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 +- plugins/offscale/PLUGIN.md | 733 ------------------ plugins/offscale/api/router.ts | 18 - plugins/offscale/db/queries.ts | 186 ----- plugins/offscale/db/schema.ts | 55 -- plugins/offscale/manifest.ts | 47 -- plugins/offscale/sidecar/active.ts | 20 - plugins/offscale/sidecar/assist.ts | 153 ---- plugins/offscale/sidecar/claude-proxy.ts | 97 --- plugins/offscale/sidecar/client.ts | 113 --- plugins/offscale/sidecar/companion.ts | 204 ----- plugins/offscale/sidecar/enroll.ts | 103 --- plugins/offscale/sidecar/index.ts | 149 ---- plugins/offscale/sidecar/invites.ts | 183 ----- plugins/offscale/sidecar/keys.ts | 119 --- plugins/offscale/sidecar/nodes.ts | 142 ---- plugins/offscale/sidecar/normalize.ts | 187 ----- plugins/offscale/sidecar/policy.ts | 115 --- plugins/offscale/sidecar/routes.ts | 80 -- plugins/offscale/sidecar/servers.ts | 201 ----- plugins/offscale/sidecar/ssh.ts | 86 -- plugins/offscale/sidecar/users.ts | 104 --- plugins/offscale/sidecar/version.ts | 79 -- plugins/offscale/web/Cards.tsx | 103 --- plugins/offscale/web/ConsoleView.tsx | 96 --- plugins/offscale/web/DiagnosticsView.tsx | 326 -------- plugins/offscale/web/HeadscaleNav.tsx | 75 -- .../offscale/web/HeadscaleServerPicker.tsx | 60 -- plugins/offscale/web/HeadscaleView.tsx | 37 - plugins/offscale/web/HeadscaleViewHeader.tsx | 23 - plugins/offscale/web/InvitesView.tsx | 394 ---------- plugins/offscale/web/KeysView.tsx | 341 -------- plugins/offscale/web/NodesView.tsx | 436 ----------- plugins/offscale/web/PolicyAssistant.tsx | 177 ----- plugins/offscale/web/PolicyView.tsx | 210 ----- plugins/offscale/web/ServerForm.tsx | 226 ------ plugins/offscale/web/ServersView.tsx | 262 ------- plugins/offscale/web/UsersView.tsx | 203 ----- plugins/offscale/web/ViewShell.tsx | 77 -- plugins/offscale/web/diff.ts | 77 -- plugins/offscale/web/format.ts | 35 - plugins/offscale/web/layout.ts | 22 - plugins/offscale/web/panels.ts | 27 - plugins/offscale/web/shared.ts | 225 ------ plugins/offscale/web/useHeadscaleCompanion.ts | 165 ---- plugins/offscale/web/useHeadscaleData.ts | 163 ---- plugins/offscale/web/useHeadscaleInvites.ts | 59 -- plugins/offscale/web/useHeadscalePolicy.ts | 79 -- plugins/offscale/web/useHeadscaleSection.ts | 12 - plugins/offscale/web/useHeadscaleServers.ts | 102 --- 50 files changed, 3 insertions(+), 7187 deletions(-) delete mode 100644 plugins/offscale/PLUGIN.md delete mode 100644 plugins/offscale/api/router.ts delete mode 100644 plugins/offscale/db/queries.ts delete mode 100644 plugins/offscale/db/schema.ts delete mode 100644 plugins/offscale/manifest.ts delete mode 100644 plugins/offscale/sidecar/active.ts delete mode 100644 plugins/offscale/sidecar/assist.ts delete mode 100644 plugins/offscale/sidecar/claude-proxy.ts delete mode 100644 plugins/offscale/sidecar/client.ts delete mode 100644 plugins/offscale/sidecar/companion.ts delete mode 100644 plugins/offscale/sidecar/enroll.ts delete mode 100644 plugins/offscale/sidecar/index.ts delete mode 100644 plugins/offscale/sidecar/invites.ts delete mode 100644 plugins/offscale/sidecar/keys.ts delete mode 100644 plugins/offscale/sidecar/nodes.ts delete mode 100644 plugins/offscale/sidecar/normalize.ts delete mode 100644 plugins/offscale/sidecar/policy.ts delete mode 100644 plugins/offscale/sidecar/routes.ts delete mode 100644 plugins/offscale/sidecar/servers.ts delete mode 100644 plugins/offscale/sidecar/ssh.ts delete mode 100644 plugins/offscale/sidecar/users.ts delete mode 100644 plugins/offscale/sidecar/version.ts delete mode 100644 plugins/offscale/web/Cards.tsx delete mode 100644 plugins/offscale/web/ConsoleView.tsx delete mode 100644 plugins/offscale/web/DiagnosticsView.tsx delete mode 100644 plugins/offscale/web/HeadscaleNav.tsx delete mode 100644 plugins/offscale/web/HeadscaleServerPicker.tsx delete mode 100644 plugins/offscale/web/HeadscaleView.tsx delete mode 100644 plugins/offscale/web/HeadscaleViewHeader.tsx delete mode 100644 plugins/offscale/web/InvitesView.tsx delete mode 100644 plugins/offscale/web/KeysView.tsx delete mode 100644 plugins/offscale/web/NodesView.tsx delete mode 100644 plugins/offscale/web/PolicyAssistant.tsx delete mode 100644 plugins/offscale/web/PolicyView.tsx delete mode 100644 plugins/offscale/web/ServerForm.tsx delete mode 100644 plugins/offscale/web/ServersView.tsx delete mode 100644 plugins/offscale/web/UsersView.tsx delete mode 100644 plugins/offscale/web/ViewShell.tsx delete mode 100644 plugins/offscale/web/diff.ts delete mode 100644 plugins/offscale/web/format.ts delete mode 100644 plugins/offscale/web/layout.ts delete mode 100644 plugins/offscale/web/panels.ts delete mode 100644 plugins/offscale/web/shared.ts delete mode 100644 plugins/offscale/web/useHeadscaleCompanion.ts delete mode 100644 plugins/offscale/web/useHeadscaleData.ts delete mode 100644 plugins/offscale/web/useHeadscaleInvites.ts delete mode 100644 plugins/offscale/web/useHeadscalePolicy.ts delete mode 100644 plugins/offscale/web/useHeadscaleSection.ts delete mode 100644 plugins/offscale/web/useHeadscaleServers.ts diff --git a/.gitignore b/.gitignore index 616f512f..498d9576 100644 --- a/.gitignore +++ b/.gitignore @@ -105,5 +105,7 @@ src/databases/officer_db/migrations/ # deliberate act, so keeping this list by hand is the right amount of friction — an unlisted plugin # directory is an install, not a source file. /plugins/*/ +# `example` is the only exception left, and only until it has a repository of its own. It is the +# reference implementation EXTRACTING-A-PLUGIN.md sends people to read, and it has no remote — the +# platform repo is its single copy, so untracking it would delete it from everywhere but this disk. !/plugins/example/ -!/plugins/offscale/ diff --git a/plugins/offscale/PLUGIN.md b/plugins/offscale/PLUGIN.md deleted file mode 100644 index 969f5d9c..00000000 --- a/plugins/offscale/PLUGIN.md +++ /dev/null @@ -1,733 +0,0 @@ -# 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/plugins/offscale/api/router.ts b/plugins/offscale/api/router.ts deleted file mode 100644 index 3d3a09a3..00000000 --- a/plugins/offscale/api/router.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/plugins/offscale/db/queries.ts b/plugins/offscale/db/queries.ts deleted file mode 100644 index ba3b1558..00000000 --- a/plugins/offscale/db/queries.ts +++ /dev/null @@ -1,186 +0,0 @@ -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/plugins/offscale/db/schema.ts b/plugins/offscale/db/schema.ts deleted file mode 100644 index 08376bb7..00000000 --- a/plugins/offscale/db/schema.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/plugins/offscale/manifest.ts b/plugins/offscale/manifest.ts deleted file mode 100644 index 2a01d9ad..00000000 --- a/plugins/offscale/manifest.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/plugins/offscale/sidecar/active.ts b/plugins/offscale/sidecar/active.ts deleted file mode 100644 index d41e7816..00000000 --- a/plugins/offscale/sidecar/active.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/plugins/offscale/sidecar/assist.ts b/plugins/offscale/sidecar/assist.ts deleted file mode 100644 index c2d220fc..00000000 --- a/plugins/offscale/sidecar/assist.ts +++ /dev/null @@ -1,153 +0,0 @@ -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/plugins/offscale/sidecar/claude-proxy.ts b/plugins/offscale/sidecar/claude-proxy.ts deleted file mode 100644 index c0472954..00000000 --- a/plugins/offscale/sidecar/claude-proxy.ts +++ /dev/null @@ -1,97 +0,0 @@ -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/plugins/offscale/sidecar/client.ts b/plugins/offscale/sidecar/client.ts deleted file mode 100644 index 5e51fb35..00000000 --- a/plugins/offscale/sidecar/client.ts +++ /dev/null @@ -1,113 +0,0 @@ -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/plugins/offscale/sidecar/companion.ts b/plugins/offscale/sidecar/companion.ts deleted file mode 100644 index c44a21c7..00000000 --- a/plugins/offscale/sidecar/companion.ts +++ /dev/null @@ -1,204 +0,0 @@ -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/plugins/offscale/sidecar/enroll.ts b/plugins/offscale/sidecar/enroll.ts deleted file mode 100644 index eed8195d..00000000 --- a/plugins/offscale/sidecar/enroll.ts +++ /dev/null @@ -1,103 +0,0 @@ -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/plugins/offscale/sidecar/index.ts b/plugins/offscale/sidecar/index.ts deleted file mode 100644 index ebb5ff53..00000000 --- a/plugins/offscale/sidecar/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -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/plugins/offscale/sidecar/invites.ts b/plugins/offscale/sidecar/invites.ts deleted file mode 100644 index 37fed1db..00000000 --- a/plugins/offscale/sidecar/invites.ts +++ /dev/null @@ -1,183 +0,0 @@ -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/plugins/offscale/sidecar/keys.ts b/plugins/offscale/sidecar/keys.ts deleted file mode 100644 index dbf3e6d5..00000000 --- a/plugins/offscale/sidecar/keys.ts +++ /dev/null @@ -1,119 +0,0 @@ -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/plugins/offscale/sidecar/nodes.ts b/plugins/offscale/sidecar/nodes.ts deleted file mode 100644 index dc34a2c9..00000000 --- a/plugins/offscale/sidecar/nodes.ts +++ /dev/null @@ -1,142 +0,0 @@ -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/plugins/offscale/sidecar/normalize.ts b/plugins/offscale/sidecar/normalize.ts deleted file mode 100644 index fc96f8ab..00000000 --- a/plugins/offscale/sidecar/normalize.ts +++ /dev/null @@ -1,187 +0,0 @@ -// 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/plugins/offscale/sidecar/policy.ts b/plugins/offscale/sidecar/policy.ts deleted file mode 100644 index 8a5a3bd2..00000000 --- a/plugins/offscale/sidecar/policy.ts +++ /dev/null @@ -1,115 +0,0 @@ -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/plugins/offscale/sidecar/routes.ts b/plugins/offscale/sidecar/routes.ts deleted file mode 100644 index c939de7f..00000000 --- a/plugins/offscale/sidecar/routes.ts +++ /dev/null @@ -1,80 +0,0 @@ -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/plugins/offscale/sidecar/servers.ts b/plugins/offscale/sidecar/servers.ts deleted file mode 100644 index 6d9c780c..00000000 --- a/plugins/offscale/sidecar/servers.ts +++ /dev/null @@ -1,201 +0,0 @@ -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/plugins/offscale/sidecar/ssh.ts b/plugins/offscale/sidecar/ssh.ts deleted file mode 100644 index 7765a7d0..00000000 --- a/plugins/offscale/sidecar/ssh.ts +++ /dev/null @@ -1,86 +0,0 @@ -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/plugins/offscale/sidecar/users.ts b/plugins/offscale/sidecar/users.ts deleted file mode 100644 index 95a9a525..00000000 --- a/plugins/offscale/sidecar/users.ts +++ /dev/null @@ -1,104 +0,0 @@ -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/plugins/offscale/sidecar/version.ts b/plugins/offscale/sidecar/version.ts deleted file mode 100644 index cd902153..00000000 --- a/plugins/offscale/sidecar/version.ts +++ /dev/null @@ -1,79 +0,0 @@ -// 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/plugins/offscale/web/Cards.tsx b/plugins/offscale/web/Cards.tsx deleted file mode 100644 index a9f9fe52..00000000 --- a/plugins/offscale/web/Cards.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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/plugins/offscale/web/ConsoleView.tsx b/plugins/offscale/web/ConsoleView.tsx deleted file mode 100644 index fe932b9d..00000000 --- a/plugins/offscale/web/ConsoleView.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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/plugins/offscale/web/DiagnosticsView.tsx b/plugins/offscale/web/DiagnosticsView.tsx deleted file mode 100644 index 9595d8ce..00000000 --- a/plugins/offscale/web/DiagnosticsView.tsx +++ /dev/null @@ -1,326 +0,0 @@ -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/plugins/offscale/web/HeadscaleNav.tsx b/plugins/offscale/web/HeadscaleNav.tsx deleted file mode 100644 index 2e14b31d..00000000 --- a/plugins/offscale/web/HeadscaleNav.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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/plugins/offscale/web/HeadscaleServerPicker.tsx b/plugins/offscale/web/HeadscaleServerPicker.tsx deleted file mode 100644 index 9ab54c90..00000000 --- a/plugins/offscale/web/HeadscaleServerPicker.tsx +++ /dev/null @@ -1,60 +0,0 @@ -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/plugins/offscale/web/HeadscaleView.tsx b/plugins/offscale/web/HeadscaleView.tsx deleted file mode 100644 index f1d35bfe..00000000 --- a/plugins/offscale/web/HeadscaleView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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/plugins/offscale/web/HeadscaleViewHeader.tsx b/plugins/offscale/web/HeadscaleViewHeader.tsx deleted file mode 100644 index 0a03c196..00000000 --- a/plugins/offscale/web/HeadscaleViewHeader.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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/plugins/offscale/web/InvitesView.tsx b/plugins/offscale/web/InvitesView.tsx deleted file mode 100644 index 81ca3f58..00000000 --- a/plugins/offscale/web/InvitesView.tsx +++ /dev/null @@ -1,394 +0,0 @@ -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/plugins/offscale/web/KeysView.tsx b/plugins/offscale/web/KeysView.tsx deleted file mode 100644 index e3b25d28..00000000 --- a/plugins/offscale/web/KeysView.tsx +++ /dev/null @@ -1,341 +0,0 @@ -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/plugins/offscale/web/NodesView.tsx b/plugins/offscale/web/NodesView.tsx deleted file mode 100644 index bdbc4795..00000000 --- a/plugins/offscale/web/NodesView.tsx +++ /dev/null @@ -1,436 +0,0 @@ -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/plugins/offscale/web/PolicyAssistant.tsx b/plugins/offscale/web/PolicyAssistant.tsx deleted file mode 100644 index 4bfed3f9..00000000 --- a/plugins/offscale/web/PolicyAssistant.tsx +++ /dev/null @@ -1,177 +0,0 @@ -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 -
- -
-