offscale, extracted from the platform into its own repository

The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:12:57 +00:00
co-authored by Claude Opus 5
commit 8a446bb4b5
50 changed files with 7188 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+733
View File
@@ -0,0 +1,733 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
---
## The reframe
**Core is `officer` and nothing else. Everything else is a plugin**`officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.
### Two words, because "core" was doing two jobs
- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.
---
## What a plugin is made of
Combined per plugin as needed. **Only `meta` and the ID are always required.**
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **permissions added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.
### What offscale needs
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
---
## Identity and routing
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.
**Uniqueness comes from two mechanisms**, because one is not enough:
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
Needed because a private plugin never asks the marketplace anything.
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.
### Mount prefixes
```
first-party /api/<app-name> e.g. /api/offscale
third-party /api/p/<creator>/<app-name> 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://<domain>/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 `<prefix>` and `<prefix>/: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 `<prefix>/*` |
| `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
`<name>: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/<dir>`. 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 `<owner>/<repo>` 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.
+18
View File
@@ -0,0 +1,18 @@
import { createSidecarProxy } from '@@/sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic.
//
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
// does not know Headscale's URL.
const proxy = createSidecarProxy({
name: 'headscale',
prefix: '/api/offscale',
});
export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl;
+186
View File
@@ -0,0 +1,186 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
// See ../crypto.ts and ../schema/headscale.ts.
//
// Two return types on purpose:
// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all.
// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
// by a route handler.
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
// into every list response the moment someone forgot to strip it.
export type HeadscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
sshHost: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
const serverCols = {
id: headscaleServers.id,
name: headscaleServers.name,
url: headscaleServers.url,
version: headscaleServers.version,
sshHost: headscaleServers.sshHost,
isActive: headscaleServers.isActive,
lastSeenAt: headscaleServers.lastSeenAt,
createdAt: headscaleServers.createdAt,
};
/** Every server the owner has registered, active first then newest. Never includes the API key. */
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
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<HeadscaleServerCredentials | null> {
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<HeadscaleServerCredentials | null> {
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<HeadscaleServer> {
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<HeadscaleServer | null> {
const set: Record<string, unknown> = { 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<HeadscaleServer | null> {
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<boolean> {
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<void> {
await db
.update(headscaleServers)
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
}
+55
View File
@@ -0,0 +1,55 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from 'officerdb/auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
//
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
// email/integrations tables are debt to avoid copying, not a precedent to follow.
//
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
// officer-headscale sidecar reads or writes these tables.
export const headscaleServers = pgTable(
'headscale_servers',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
url: text('url').notNull(),
apiKey: text('api_key').notNull(), // encrypted
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
version: text('version'),
// Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the
// API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately
// NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname
// stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the
// connection uses whatever ~/.ssh already knows, so there is no credential here to protect.
sshHost: text('ssh_host'),
isActive: boolean('is_active').notNull().default(false),
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url),
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two servers active.
uniqueIndex('uq_headscale_servers_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
+47
View File
@@ -0,0 +1,47 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Offscale — Headscale, plus the Companion that ships beside it.
//
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
// distinct product rather than a badge on someone else's.
//
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
//
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
// sidecar/ the whole Headscale contract, holding the admin API keys
// db/ offscale_servers, and the only table this plugin owns
// web/ panels and a layout; the shell renders the Workspace
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
icon: 'Network',
color: '#818cf8',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
// That is a change inside these queries, not a flag on the manifest.
//
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
// full control of the tailnet, which is the owner's decision to make deliberately.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes, keys and ACLs',
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
// Without declaring them a read-level account meets a broken feature where a withheld permission
// should be. Inert while ownerOnly, and correct the moment that changes.
readOnlyWrites: ['/ssh-test', '/policy/assist'],
},
],
};
+20
View File
@@ -0,0 +1,20 @@
import { getActiveHeadscaleCredentials } from '../db/queries';
import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so
// no client can act on a server the owner isn't currently looking at by guessing an id.
/**
* The client for the active server, or a ready-to-send 409 when there isn't one.
*
* 409 rather than 404: the route exists and the request was well-formed, the account just has no server
* selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone".
*/
export async function activeClient(userId: number): Promise<HeadscaleClient | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return createClient(creds);
}
+153
View File
@@ -0,0 +1,153 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { arrayField, toNode, toUser } from './normalize';
import { askClaude, ProxyUnavailable } from './claude-proxy';
// `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back.
//
// The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar,
// and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not
// "typing is slow", it is "I do not know what the file is supposed to look like".
//
// THREE THINGS THIS DELIBERATELY DOES NOT DO.
//
// 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is
// written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff.
// A model that could write the ACL directly is a model that can partition the network the owner is
// connected through — including the SSH route back in to fix it.
// 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a
// proposal that looks fine here and is refused on save is a normal, visible outcome.
// 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the
// rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses.
//
// The current document is sent in full and the reply must be the full replacement, not a patch. Patches
// against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this
// file's whole premise is that those are worth keeping.
const MODEL = 'claude-sonnet-5';
const MAX_TOKENS = 8_000;
/** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */
const MAX_PROMPT_CHARS = 2_000;
/** Enough context to write rules against without pasting an entire large tailnet into the request. */
const MAX_NODES = 60;
const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy.
The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format:
groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS
features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic
"acls" entries unless the existing document already uses grants.
Rules for your reply, in this order:
1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or
denies. No preamble, no restating the request.
2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an
excerpt — the whole file, ready to replace what is there.
Preserve the existing document's comments, key order and indentation wherever your change does not touch
them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist
in the context given to you, or that you also define in the same document. If the request is ambiguous enough
that you would have to guess at something consequential, say so in the paragraph and make the narrower,
safer choice rather than asking a question — the owner reviews a diff before anything is saved.
If the request cannot be expressed in this policy at all, say why in the paragraph and return the document
unchanged in the code block.`;
type TailnetContext = { users: string[]; tags: string[]; nodes: string[] };
/**
* The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are
* called. Best-effort — a server that will not answer these still gets an assistant, just a less informed
* one, which beats failing the request over context that is an optimisation.
*/
async function readContext(userId: number): Promise<TailnetContext> {
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<Response> {
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;
}
}
+97
View File
@@ -0,0 +1,97 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It
// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally
// generated secret it writes to its own state file, so authenticating is a file read, not a credential this
// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts
// (`readProxySecretFromDisk`), which does the same thing for the agent.
//
// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this
// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle.
//
// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat
// surface, which already exists and already persists.
const DEFAULT_TIMEOUT_MS = 120_000;
/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */
export class ProxyUnavailable extends Error {}
/**
* The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a
* freshly installed machine has a window where the file exists without it.
*/
function readProxySecret(): string {
try {
const file = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(file)) return '';
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown };
return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : '';
} catch {
return '';
}
}
type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number };
type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } };
/**
* One user turn, one reply, as plain text.
*
* The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates
* with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something
* else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is
* currently accepted too. Keeping the block costs ~14 tokens and removes the question.)
*/
export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise<string> {
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;
}
+113
View File
@@ -0,0 +1,113 @@
import type { HeadscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
// • Auth is `Authorization: Bearer <apiKey>`. 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<string> {
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: <T>(path: string, opts?: CallOptions) => Promise<T>;
};
/** Build a client bound to one registered server's credentials. */
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const headers: Record<string, string> = {
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 };
}
+204
View File
@@ -0,0 +1,204 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
// Contract: COMMS/HEADSCALE_COMPANION_API.md.
//
// Three facts shape everything here.
//
// 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already
// store, validated locally against Headscale's own key store — so auth keeps working while Headscale
// is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never
// leaves this sidecar.
//
// 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So
// "no companion" is a normal state, not an error: every route below answers 200 with
// `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do.
// Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down,
// the companion returns JSON when a docker op fails. Branch on whether the body parses.
//
// 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read
// `verdict`. That inversion is deliberate on their side and is preserved on ours.
/**
* Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a
* companion is a state to render, not a request that failed — the admin API on the same domain is
* independent and may still be working, so this must not surface as an error the UI swallows.
*/
export const unavailable = (reason: string) => ({ available: false as const, reason });
const DEFAULT_TIMEOUT_MS = 20_000;
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
/**
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
* itself could not be reached — the caller decides how to present that, because for this feature
* "unreachable" is information rather than a failure.
*/
export async function callCompanion(
creds: HeadscaleServerCredentials,
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
): Promise<Response | string> {
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<Record<string, unknown> | 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<string, unknown>;
} 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<HeadscaleServerCredentials | Response> {
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<Response> {
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<Response> {
const tail = Number(url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
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<Response> {
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
// 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<Response> {
// 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<Response> {
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();
}
+103
View File
@@ -0,0 +1,103 @@
import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize';
import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
// like every other domain route here, and the platform holds no Headscale credentials at all.
//
// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core
// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and
// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not.
/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */
const KEY_TTL_MS = 10 * 60_000;
/**
* Which Headscale user the joining device is filed under.
*
* An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the
* server means there is nothing to choose. Several means the caller has to say, because picking silently
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
*/
async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
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<Response | null> {
// `/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 });
}
+149
View File
@@ -0,0 +1,149 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '@@/officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
//
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
// generated on that server, and switches between them; one is active at a time. So configuration lives in
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
//
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
// different question and needs an owner, so it lives below.
// GET /_officer/servers registered servers (never includes API keys)
// POST /_officer/servers register {name?,url,apiKey} — validated before it is saved
// PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes
// DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active
// POST /_officer/servers/:id/activate switch the active server
// GET /_officer/servers/:id/health probe: reachable? version? key still accepted?
//
// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts.
//
// GET /_officer/nodes nodes, normalized; ?user=<username> 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<string, unknown>).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'));
+183
View File
@@ -0,0 +1,183 @@
import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
// key it never had to be told.
//
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
//
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
// the moment it is handed to the admin." The create response passes through this process once, in memory,
// on its way to the browser — that is the whole of its life here.
//
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
// companion route: most registered servers have no companion at all, and that is a state to render rather
// than a request that failed.
/**
* Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is
* `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container
* routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift.
*/
const INVITES_PATH = '/api/v1/enroll/invites';
/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
const DEFAULT_TTL_SECONDS = 900;
const MIN_TTL_SECONDS = 60;
const MAX_TTL_SECONDS = 86_400;
type CreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note?: string;
};
/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
function parseCreate(body: Record<string, unknown> | 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<string, unknown>) => unknown): Promise<Response> {
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=<percent-encoded>`.
*
* 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<Response> {
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<string, unknown>) : {};
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<string, unknown>): 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<Response> {
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<Response> {
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<Response> {
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);
}
+119
View File
@@ -0,0 +1,119 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toPreAuthKey, arrayField } from './normalize';
// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet.
//
// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real
// secret ONLY in the create response. Every later list returns it masked as `hskey-auth-<prefix>-***`. 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<Response> {
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<Response> {
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<string, unknown> }>('/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<Response> {
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<Response> {
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] });
}
+142
View File
@@ -0,0 +1,142 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import type { HeadscaleClient } from './client';
import { activeClient } from './active';
import { toNode, arrayField, type OfficerNode } from './normalize';
// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet.
//
// Two upstream shapes are worth knowing before reading this:
//
// • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be
// encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route.
// • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an
// add/remove. Approving one route means sending every route that should remain approved, so those
// operations are read-modify-write here rather than in the browser — see rule 5 in
// SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits;
// doing it here still races, but over milliseconds instead of however long a form sits open.
/** Nodes on the active server, newest-registered first within each user. */
async function listNodes(ctx: OfficerContext): Promise<Response> {
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<OfficerNode | null> {
const body = await client.call<{ node?: Record<string, unknown> }>(`/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<Response> {
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<Response> {
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] });
}
+187
View File
@@ -0,0 +1,187 @@
// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there.
//
// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize
// here so nothing downstream has to know:
//
// 1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks
// silently above 2^53 and Headscale's ids are database-assigned, not small by contract.
// 2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than
// omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null.
// 3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no
// way to distinguish "unset" from "empty", so every accessor tolerates both.
/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */
const ZERO_TIME = '0001-01-01T00:00:00Z';
/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */
export function isoOrNull(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null;
const ms = Date.parse(raw);
if (Number.isNaN(ms)) return null;
// Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too.
return ms < 31_536_000_000 ? null : new Date(ms).toISOString();
}
const str = (raw: unknown): string => (typeof raw === 'string' ? raw : '');
const strArray = (raw: unknown): string[] =>
Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
export type UpstreamUser = Record<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;
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<string, string> = {
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-<prefix>-***`.
* 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<string, unknown>[] {
const value = (body as Record<string, unknown> | null)?.[field];
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}
+115
View File
@@ -0,0 +1,115 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { HeadscaleError } from './client';
import { activeClient } from './active';
import { handlePolicyAssistRoute } from './assist';
// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is
// the highest-consequence thing this app can write and the only place a typo silently partitions a network.
//
// Three upstream behaviours drive the shape of this file.
//
// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`)
// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite
// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified
// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint
// that reports it either. So this route makes no claim about writability up front; the first save is
// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner.
//
// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the
// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a
// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here
// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
//
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.
/**
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
*
* Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive
* costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell
* someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there.
*/
function isWriteDisabled(detail: string): boolean {
const text = detail.toLowerCase();
if (text.includes('disabled')) return true;
return text.includes('file') && (text.includes('policy') || text.includes('mode'));
}
type PolicyBody = { policy?: unknown; updatedAt?: unknown };
const asText = (value: unknown) => (typeof value === 'string' ? value : '');
const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null);
/**
* `GET /_officer/policy`.
*
* Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh
* Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner
* needs to start typing into. Only an unreachable server is an error, because only that leaves nothing
* to say. Note there is no `mode` here on purpose: see the header.
*/
async function getPolicy(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
try {
const body = await client.call<PolicyBody>('/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<Response> {
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<PolicyBody>('/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<Response> {
// `/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();
}
+80
View File
@@ -0,0 +1,80 @@
import { HeadscaleError } from './client';
import { handleServersRoute } from './servers';
import { handleNodesRoute } from './nodes';
import { handleUsersRoute } from './users';
import { handleKeysRoute } from './keys';
import { handlePolicyRoute } from './policy';
import { handleEnrollRoute } from './enroll';
import { handleSshTestRoute } from './ssh';
import { handleCompanionRoute } from './companion';
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
//
// Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings,
// dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth
// keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the
// browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md.
export type OfficerContext = { req: Request; url: URL; userId: number };
/** 400 with a machine-readable reason. */
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** 404 for an unknown /_officer/ path or a missing object. */
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
/** 405 when the path exists but the verb doesn't. */
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
/** Parse a JSON request body, or null when there isn't one / it isn't an object. */
export async function readJson(req: Request): Promise<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : 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<Response | null> {
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;
}
}
+201
View File
@@ -0,0 +1,201 @@
import type { OfficerContext } from './routes';
import {
listHeadscaleServers,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from '../db/queries';
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
import { normalizeSshHost } from './ssh';
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
//
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
// discovered later as a confusing failure on some unrelated screen:
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
// 2. an authenticated call — proves the key actually works
// Neither step is skippable, and a rejected registration is never written.
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
function normalizeUrl(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
let candidate = raw.trim();
// Bare host/port is the most common paste; assume https rather than rejecting it.
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
}
function requireString(value: unknown, field: string): string | Response {
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
return value.trim();
}
/**
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
* Returns the observed version on success, or a ready-to-send error Response.
*/
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
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<Response> {
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<string, unknown> | 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<Response> {
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<string, unknown> | 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<Response> {
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]);
}
+86
View File
@@ -0,0 +1,86 @@
import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes';
// SSH console support — the escape hatch for when the Headscale API cannot answer.
//
// Officer never handles a password, a key or a port here. The console runs `ssh <host>` 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<SshProbe> {
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<Response> {
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));
}
+104
View File
@@ -0,0 +1,104 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toUser, toNode, arrayField, type OfficerUser } from './normalize';
// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys.
//
// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its
// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a
// surprise. That is one extra upstream call for the whole list, not one per user.
export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number };
async function listUsers(ctx: OfficerContext): Promise<Response> {
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<string, { total: number; online: number }>();
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<Response> {
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<string, unknown> }>('/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<Response> {
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<string, unknown> }>(
`/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<Response> {
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] });
}
+79
View File
@@ -0,0 +1,79 @@
// Headscale version detection and the supported floor.
//
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at
// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth
// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.230.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<VersionProbe> {
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) };
}
+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
// cards on hairline white borders. Kept local to the app so the look changes in one place.
export const Card = ({ children }: { children: ReactNode }) => (
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
);
export const SectionHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<div className="flex items-start justify-between gap-4 px-1 pb-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-zinc-500">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
type?: 'button' | 'submit';
variant?: 'primary' | 'ghost' | 'danger';
disabled?: boolean;
title?: string;
};
const VARIANTS: Record<NonNullable<ButtonProps['variant']>, 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) => (
<button
type={type}
onClick={onClick}
disabled={disabled}
title={title}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-40 ${VARIANTS[variant]}`}
>
{children}
</button>
);
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) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">{label}</span>
<input
type={type}
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
spellCheck={false}
autoComplete="off"
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50"
/>
{hint && <span className="text-[11px] leading-snug text-zinc-600">{hint}</span>}
</label>
);
/** 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 <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} />;
};
export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${
tone === 'active' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-white/10 text-zinc-400'
}`}
>
{children}
</span>
);
export const ErrorNote = ({ children }: { children: ReactNode }) => (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs leading-snug text-red-300">
{children}
</div>
);
+96
View File
@@ -0,0 +1,96 @@
import { useCallback } from 'react';
import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from 'officerdev';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
// answer (why headscale won't start, what the logs say, whether the disk is full).
//
// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh <host>` 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 }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<TerminalSquare className="h-6 w-6" />
</div>
{children}
</div>
);
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 (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (!active) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to open its console.</p>
</div>
</Centred>
);
}
if (!active.sshHost) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No SSH address for {active.name}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">
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.
</p>
</div>
<Link to={headscaleSectionPath('servers')}>
<Button variant="primary">Go to Servers</Button>
</Link>
</Centred>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<TerminalSquare className="h-3.5 w-3.5" />
<span className="truncate">
ssh <span className="font-mono text-zinc-300">{active.sshHost}</span> · {active.name}
</span>
</div>
<TerminalView
// Remount on a server switch: the session id is a mount-time argument, so without this the panel would
// keep showing the previous server's shell under the new server's name.
key={active.id}
className="min-h-0 flex-1 p-2"
sessionId={consoleSessionId(active.id)}
initialInput={`ssh ${active.sshHost}`}
onConnectionChange={onConnectionChange}
/>
</div>
);
};
+326
View File
@@ -0,0 +1,326 @@
import { useEffect, useRef, useState } from 'react';
import {
Activity,
AlertTriangle,
Loader2,
Play,
PlugZap,
RotateCw,
ScrollText,
Square,
Trash2,
Unplug,
} from 'lucide-react';
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
import { timeAgo } from './format';
import { useHeadscaleServers } from './useHeadscaleServers';
import {
useCompanionAction,
useCompanionHealth,
useCompanionLogStream,
useCompanionLogs,
} from './useHeadscaleCompanion';
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// What the admin API structurally cannot tell you: is the container running, what did it log on the way
// down, and can we bring it back. All of it comes from the Officer Companion deployed alongside the server.
//
// The companion is optional and per-server, so "not deployed" is the ordinary case for a server that has
// never had one, and is rendered as an explanation rather than an error. Note the two inversions this
// section is built around: the companion's /health is ALWAYS HTTP 200 (read `verdict`, never the status),
// and an unavailable companion says nothing about Headscale itself — the admin API on the same domain is
// independent and the other sections may be working perfectly.
const VERDICTS: Record<CompanionVerdict, { tone: 'ok' | 'warn' | 'bad' | 'idle'; label: string; blurb: string }> = {
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 }) => (
<div className="flex items-baseline justify-between gap-4 py-1.5 text-xs">
<span className="shrink-0 text-zinc-500">{label}</span>
<span className="min-w-0 truncate text-right text-zinc-300">{value}</span>
</div>
);
const ContainerFacts = ({ container }: { container: CompanionContainer }) => (
<div className="divide-y divide-white/5">
<Row label="Container" value={container.status} />
<Row label="Healthcheck" value={container.healthcheck ?? 'none defined'} />
<Row label="Started" value={timeAgo(container.startedAt)} />
{!container.running && !isZeroDate(container.finishedAt) && (
<Row label="Exited" value={`${timeAgo(container.finishedAt)} · code ${container.exitCode}`} />
)}
<Row label="Restarts" value={container.restartCount === 0 ? 'none' : `${container.restartCount} by docker`} />
</div>
);
/** 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 (
<div className="flex flex-col gap-3 border-t border-white/10 p-4">
{causes.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
Likely causes
</div>
<ul className="flex flex-col gap-1">
{causes.map((cause) => (
<li key={cause} className="rounded-md bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-200/90">
{cause}
</li>
))}
</ul>
{/* 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. */}
<p className="mt-1 text-[11px] text-zinc-600">Guessed from the logs treat them as leads, not answers.</p>
</div>
)}
{health.healthcheckOutput && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Healthcheck output</div>
<pre className="overflow-x-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] whitespace-pre-wrap text-zinc-400">
{health.healthcheckOutput}
</pre>
</div>
)}
{recent.length > 0 && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Last lines before now</div>
<pre className="max-h-48 overflow-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] text-zinc-400">
{recent.join('\n')}
</pre>
</div>
)}
</div>
);
};
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<HTMLPreElement>(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 (
<Card>
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-3 py-2">
<div className="mr-auto flex items-center gap-1.5 text-xs font-medium text-zinc-300">
<ScrollText className="h-3.5 w-3.5" />
Logs
{follow && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-zinc-500">
<Dot tone={stream.live ? 'ok' : 'idle'} />
{stream.live ? 'live' : 'stopped'}
</span>
)}
</div>
<div className="flex items-center gap-1">
{TAILS.map((n) => (
<Button key={n} variant={tail === n ? 'primary' : 'ghost'} onClick={() => setTail(n)}>
{n}
</Button>
))}
</div>
<Button variant={follow ? 'primary' : 'ghost'} onClick={() => setFollow((on) => !on)}>
{follow ? <Unplug className="h-3.5 w-3.5" /> : <PlugZap className="h-3.5 w-3.5" />}
{follow ? 'Stop' : 'Follow'}
</Button>
{follow ? (
<Button onClick={stream.clear} title="Clear what has been received">
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<Button onClick={() => void snapshot.refetch()} disabled={snapshot.isFetching}>
{snapshot.isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
Refresh
</Button>
)}
</div>
{stream.error && (
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-red-300">{stream.error}</div>
)}
{unavailable && <div className="border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">{unavailable}</div>}
<pre
ref={boxRef}
className="max-h-[26rem] min-h-[12rem] overflow-auto bg-black/40 p-3 font-mono text-[11px] leading-relaxed text-zinc-400"
>
{lines.length > 0
? lines.join('\n')
: snapshot.isLoading
? 'Loading…'
: follow
? 'Waiting for output…'
: 'No log lines.'}
</pre>
</Card>
);
};
const Lifecycle = ({ running }: { running: boolean | null }) => {
const action = useCompanionAction();
const [pending, setPending] = useState<CompanionAction | null>(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 (
<div className="flex flex-col gap-2 border-t border-white/10 p-4">
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map(({ id, label, icon: Icon, confirm }) => (
<Button
key={id}
variant={id === 'stop' ? 'danger' : 'ghost'}
disabled={pending !== null || (running !== null && (id === 'start' ? running : !running))}
onClick={() => void run(id, confirm)}
title={id === 'start' && running ? 'Already running' : undefined}
>
{pending === id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
{label}
</Button>
))}
<span className="text-[11px] text-zinc-600">Acts on the container, not on Officer.</span>
</div>
{action.error != null && <ErrorNote>The action could not be sent.</ErrorNote>}
{result && !result.available && <ErrorNote>{result.reason}</ErrorNote>}
{result && result.available && !result.ok && (
<ErrorNote>{result.error ?? 'Docker refused the action.'}</ErrorNote>
)}
{result && result.available && result.ok && (
<div className="text-[11px] text-emerald-300">
{result.action}: {result.result}
</div>
)}
</div>
);
};
export const DiagnosticsView = () => {
const { active } = useHeadscaleServers();
const query = useCompanionHealth();
const result = query.data;
return (
<ViewShell isLoading={query.isLoading} error={query.error} label="diagnostics">
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<SectionHeader
title="Diagnostics"
subtitle={active ? `The container behind ${active.name}, as seen from the machine it runs on.` : undefined}
action={query.isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin text-zinc-600" /> : undefined}
/>
{result && !result.available ? (
<Card>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Activity className="h-4 w-4 text-zinc-500" />
No companion on this server
</div>
<p className="text-xs leading-relaxed text-zinc-500">
{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.
</p>
<p className="text-xs leading-relaxed text-zinc-600">
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.
</p>
</div>
</Card>
) : result ? (
<>
<Card>
<div className="flex items-start gap-3 p-4">
<div className="mt-1">
<Dot tone={VERDICTS[result.health.verdict].tone} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-100">{VERDICTS[result.health.verdict].label}</div>
<p className="mt-0.5 text-xs text-zinc-500">
{result.health.reason ?? VERDICTS[result.health.verdict].blurb}
</p>
</div>
</div>
<div className="border-t border-white/10 px-4 py-2">
<Row label="Control plane" value={result.health.connected ? 'answering' : 'not answering'} />
{result.health.probe && <Row label="Probe" value={result.health.probe} />}
{result.health.container && <ContainerFacts container={result.health.container} />}
</div>
<Evidence health={result.health} />
<Lifecycle running={result.health.container?.running ?? null} />
</Card>
<LogViewer />
</>
) : null}
</div>
</ViewShell>
);
};
+75
View File
@@ -0,0 +1,75 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
//
// Sections are real links to /headscale/<section>, 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<HeadscaleSectionId, LucideIcon> = {
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 && <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
<Icon
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</>
);
export const HeadscaleNav = () => {
const { active } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<nav className="flex flex-col gap-0.5 px-2 py-3">
{HEADSCALE_SECTIONS.map(({ id, label }) => {
// Without an active server the domain sections have nothing to act on, so they are rendered as
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
// useful is worse than no link.
if (id !== 'servers' && !active) {
return (
<span key={id} title="Select a server first" className={`${ROW} cursor-default opacity-40`}>
<SectionBody icon={ICONS[id]} label={label} selected={false} />
</span>
);
}
return (
<NavLink
key={id}
to={headscaleSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => <SectionBody icon={ICONS[id]} label={label} selected={isActive} />}
</NavLink>
);
})}
</nav>
</div>
);
};
+60
View File
@@ -0,0 +1,60 @@
import { Check, Network, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Top-left panel of the /headscale workspace: which server everything else acts on.
//
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
// section list below is real links.
//
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
export const HeadscaleServerPicker = () => {
const { servers, active, activate, isLoading } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
</div>
</div>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{servers.map((server) => (
<button
key={server.id}
type="button"
onClick={() => !server.isActive && activate.mutate(server.id)}
disabled={activate.isPending}
title={server.url}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
server.isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
))}
{servers.length === 0 && !isLoading && (
<Link
to={headscaleSectionPath('servers')}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5 shrink-0" />
Register a server
</Link>
)}
</div>
</div>
);
};
+37
View File
@@ -0,0 +1,37 @@
import { useHeadscaleSection } from './useHeadscaleSection';
import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
import { InvitesView } from './InvitesView';
import { PolicyView } from './PolicyView';
import { DiagnosticsView } from './DiagnosticsView';
import { ConsoleView } from './ConsoleView';
// Right panel of the /headscale workspace — renders the section named by the URL.
//
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => {
const section = useHeadscaleSection();
switch (section) {
case 'nodes':
return <NodesView />;
case 'users':
return <UsersView />;
case 'keys':
return <KeysView />;
case 'invites':
return <InvitesView />;
case 'policy':
return <PolicyView />;
case 'diagnostics':
return <DiagnosticsView />;
case 'console':
return <ConsoleView />;
default:
return <ServersView />;
}
};
+23
View File
@@ -0,0 +1,23 @@
import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers';
import { HEADSCALE_SECTIONS } from './shared';
import { useHeadscaleSection } from './useHeadscaleSection';
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
export const HeadscaleViewHeader = () => {
const section = useHeadscaleSection();
const { active } = useHeadscaleServers();
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
return (
<>
<Network className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{active && <span className="ml-1.5 font-normal text-black/50">· {active.name}</span>}
</span>
</>
);
};
+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
import { useHeadscaleInvites } from './useHeadscaleInvites';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { fullDate, timeAgo, timeUntil } from './format';
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
import { EmptyBody, ViewShell } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
//
// The point of the feature is that the person joining does nothing but tap a link and press Join: no app
// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin
// does all of it here and sends one link.
//
// TWO RULES SHAPE THIS FILE.
//
// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display,
// log or store it beyond the moment it is handed to the admin. So the created invite lives in component
// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and
// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another.
// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted
// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole
// reason the credential is not in the URL.
const STATUS_TONE: Record<InviteStatus, 'ok' | 'warn' | 'bad' | 'idle'> = {
pending: 'warn',
claimed: 'ok',
expired: 'idle',
revoked: 'bad',
};
/** Presets rather than a free number: every one is inside the spec's 60s24h 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 (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
/**
* 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<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvas.current) return;
void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 });
}, [url]);
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
};
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 (
<div className="overflow-hidden rounded-xl border border-primary/30 bg-primary/[0.06]">
<div className="flex items-start gap-2.5 border-b border-primary/20 px-4 py-3">
<Smartphone className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-100">Send this link to the device</div>
<p className="mt-0.5 text-xs leading-snug text-zinc-400">
It opens OffScale, shows one confirmation screen and joins as{' '}
<span className="text-zinc-200">{invite.user}</span>. Single use, and it stops working{' '}
{timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy dismiss this and it is gone.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-300">
{invite.url}
</div>
{showQr && (
<div className="flex justify-center py-1">
<InviteQr url={invite.url} />
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={invite.url} label="Copy link" />
{/* 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' && (
<Button onClick={share}>
<Share2 className="h-3.5 w-3.5" />
Share
</Button>
)}
<Button onClick={() => setShowQr((v) => !v)}>
<QrCode className="h-3.5 w-3.5" />
{showQr ? 'Hide QR' : 'Show QR'}
</Button>
<Button variant="danger" onClick={onDismiss}>
Done
</Button>
</div>
</div>
</div>
);
};
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<string | null>(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 (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">Authorize a new device</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={user || users[0]?.name || ''}
onChange={(ev) => setUser(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((entry) => (
<option key={entry.id} value={entry.name}>
{entry.name}
</option>
))}
</select>
</label>
<Field
label="Device name (optional)"
value={note}
onChange={setNote}
placeholder="andre-iphone"
hint="Prefilled on the phone's join screen and used as the node's name, which the person can edit. It labels this invite in your list too."
/>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">Link valid for</span>
<select
value={ttl}
onChange={(ev) => setTtl(Number(ev.target.value))}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{TTL_OPTIONS.map((option) => (
<option key={option.seconds} value={option.seconds}>
{option.label}
</option>
))}
</select>
<span className="text-[11px] leading-snug text-zinc-600">
How long the link can be claimed for. Short is safer you can always mint another.
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={ephemeral}
onChange={(ev) => setEphemeral(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">Ephemeral</span>
<span className="block text-[11px] leading-snug text-zinc-600">
The node is removed when it goes offline. Wrong for a phone; right for a container.
</span>
</span>
</label>
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="phone, family"
hint="Comma or space separated. The tag: prefix is added for you, and the device cannot change them."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create invite
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
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 (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[invite.status] ?? 'idle'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm text-zinc-200">{invite.note || 'Untitled invite'}</span>
<Badge>{invite.user}</Badge>
{invite.ephemeral && <Badge>ephemeral</Badge>}
{invite.tags?.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{invite.status}</span>
{invite.status === 'pending' && (
<span title={fullDate(invite.expiresAt ?? null)}>· expires {timeUntil(invite.expiresAt ?? null)}</span>
)}
{invite.status === 'claimed' && (
<span title={fullDate(invite.claimedAt ?? null)}>
· claimed {timeAgo(invite.claimedAt ?? null)}
{invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''}
</span>
)}
<span>· created {timeAgo(invite.createdAt ?? null)}</span>
</div>
</div>
{/* 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' && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{confirming ? (
<>
<Button variant="danger" onClick={() => void run()} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Confirm revoke
</Button>
<Button onClick={() => setConfirming(false)} disabled={revoke.isPending}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
)}
</div>
)}
</div>
</Card>
);
};
export const InvitesView = () => {
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const pending = invites.filter((i) => i.status === 'pending').length;
return (
<ViewShell isLoading={isLoading} error={error} label="device invites">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Device invites</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`}
</p>
</div>
{!creating && !unavailable && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
Authorize new device
</Button>
)}
</div>
{/* 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 && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
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 && <InviteLinkPanel invite={created} onDismiss={() => setCreated(null)} />}
{creating && <CreateInviteForm onCreated={setCreated} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{!unavailable && invites.length === 0 && !creating && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
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) => (
<InviteRow key={invite.id} invite={invite} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+341
View File
@@ -0,0 +1,341 @@
import { useState } from 'react';
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
import type { HeadscalePreAuthKey } from './shared';
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Pre-auth keys — the tokens a machine presents to join the tailnet.
//
// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the
// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the
// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy
// and the ready-to-paste join command, and only clears on an explicit dismiss.
//
// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones.
const STATUS_FILTERS = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
] as const;
type StatusFilter = (typeof STATUS_FILTERS)[number]['id'];
const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
return (
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
Headscale stores it hashed. Once you dismiss this, nothing not Officer, not the server can show it
again.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
{secret}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
{command}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={secret} label="Copy key" />
<CopyButton value={command} label="Copy command" />
<Button variant="danger" onClick={onDismiss}>
I've saved it
</Button>
</div>
</div>
</div>
);
};
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={(ev) => onChange(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">{label}</span>
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
</span>
</label>
);
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<string | null>(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 (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New pre-auth key</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={userId || users[0]?.id || ''}
onChange={(ev) => setUserId(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2">
<Toggle
checked={reusable}
onChange={setReusable}
label="Reusable"
hint="Any number of machines can join with it, until it expires."
/>
<Toggle
checked={ephemeral}
onChange={setEphemeral}
label="Ephemeral"
hint="Nodes that join with it are removed when they go offline. For containers and CI."
/>
</div>
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="server, ci"
hint="Comma separated. The tag: prefix is added for you."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create key
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
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<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[entry.status]} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
{entry.user && <Badge>{entry.user.name}</Badge>}
{entry.reusable && <Badge>reusable</Badge>}
{entry.ephemeral && <Badge>ephemeral</Badge>}
{entry.aclTags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{entry.status}</span>
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
<span>· created {timeAgo(entry.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{entry.status === 'active' && (
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
<TimerOff className="h-3.5 w-3.5" />
Expire
</Button>
)}
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm delete
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
export const KeysView = () => {
const { keys, isLoading, error } = useHeadscaleKeys();
const { active } = useHeadscaleServers();
const [creating, setCreating] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>('active');
const [actionError, setActionError] = useState<string | null>(null);
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
const activeCount = keys.filter((k) => k.status === 'active').length;
return (
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{activeCount} active of {keys.length}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
{STATUS_FILTERS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{option.label}
</button>
))}
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New key
</Button>
)}
</div>
</div>
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{keys.length === 0 && !creating && (
<EmptyBody
icon={<KeyRound className="h-6 w-6" />}
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 && (
<div className="py-10 text-center text-sm text-zinc-500">
No active keys. Switch to All to see spent and expired ones.
</div>
)}
{visible.map((entry) => (
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+436
View File
@@ -0,0 +1,436 @@
import { useState } from 'react';
import {
Laptop,
Globe,
Trash2,
Pencil,
TimerReset,
Check,
X,
Search,
Copy,
ChevronRight,
UserRound,
ArrowRightLeft,
Tag as TagIcon,
} from 'lucide-react';
import type { HeadscaleNode } from './shared';
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// The nodes section — the machines in the tailnet.
//
// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node
// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
const copy = (text: string) => void copyToClipboard(text);
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => {
const isExit = route === '0.0.0.0/0' || route === '::/0';
return (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
<button
type="button"
disabled={busy}
onClick={() => onToggle(!approved)}
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
approved
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
}`}
>
{approved ? 'Approved' : 'Approve'}
</button>
</div>
);
};
/**
* 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<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<div className="flex flex-col gap-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<select
value={owner}
onChange={(ev) => setOwner(ev.target.value)}
disabled={busy || pending}
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
>
{!node.user && <option value="">no owner</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{ownerDirty && (
<>
<Button
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
disabled={busy || pending}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
Move
</Button>
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
Cancel
</Button>
</>
)}
</div>
{/* 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 && (
<p className="text-[11px] leading-snug text-amber-400/90">
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> 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.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<input
value={draftTags}
onChange={(ev) => 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 && (
<>
<Button
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
disabled={busy || pending}
>
<Check className="h-3.5 w-3.5" />
Apply tags
</Button>
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
Revert
</Button>
</>
)}
</div>
<p className="text-[11px] leading-snug text-zinc-600">
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 <span className="text-zinc-500">tag:</span> prefix is added for you.
</p>
</div>
);
};
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<unknown>) => {
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 (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2.5 px-3.5 py-3">
<Dot tone={node.online ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(ev) => 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"
/>
<button
type="button"
onClick={() => void submitRename()}
className="cursor-pointer p-1 text-emerald-400"
>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">
<span className="tabular-nums text-zinc-500">{node.id}:</span> {node.hostname}{' '}
<span className="font-normal text-zinc-500">({node.name})</span>
</span>
{node.user && <Badge>{node.user.name}</Badge>}
{node.isExitNode && <Badge>exit</Badge>}
{node.tags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
</div>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
>
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
</div>
{open && (
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="text-zinc-500">Addresses</div>
<div className="flex flex-col gap-0.5">
{node.ipAddresses.map((ip) => (
<button
key={ip}
type="button"
onClick={() => copy(ip)}
title="Copy"
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
>
{ip}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
))}
</div>
<div className="text-zinc-500">Hostname</div>
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
<div className="text-zinc-500">Registered</div>
<div className="text-zinc-300">
{timeAgo(node.createdAt)} · {node.registerMethod}
</div>
<div className="text-zinc-500">Key expires</div>
<div className="text-zinc-300" title={fullDate(node.expiry)}>
{timeUntil(node.expiry)}
</div>
<div className="text-zinc-500">Last seen</div>
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
{node.online ? 'now' : timeAgo(node.lastSeen)}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Advertised routes
</div>
{node.availableRoutes.length === 0 ? (
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
) : (
<div className="flex flex-col gap-1">
{node.availableRoutes.map((route) => (
<RouteRow
key={route}
route={route}
approved={node.approvedRoutes.includes(route)}
busy={busy}
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
/>
))}
</div>
)}
</div>
<Ownership node={node} busy={busy} onError={onError} />
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraftName(node.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
<Button
onClick={() => void run(() => expire.mutateAsync(node.id))}
disabled={busy}
title="Expire the node's key — it stays registered but must re-authenticate"
>
<TimerReset className="h-3.5 w-3.5" />
Force re-auth
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
)}
</div>
</Card>
);
};
export const NodesView = () => {
const { nodes, isLoading, error } = useHeadscaleNodes();
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(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 (
<ViewShell isLoading={isLoading} error={error} label="nodes">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div className="flex items-center gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{nodes.length} registered · {online} online
</p>
</div>
<div className="relative w-56 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
<input
value={filter}
onChange={(ev) => 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"
/>
</div>
</div>
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{nodes.length === 0 && (
<EmptyBody
icon={<Laptop className="h-6 w-6" />}
title="No nodes yet"
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
/>
)}
{nodes.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches {filter}.</div>
)}
{visible.map((node) => (
<NodeCard key={node.id} node={node} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+177
View File
@@ -0,0 +1,177 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
import { collapseUnchanged, diffCounts, diffLines } from './diff';
import { Button, Card, ErrorNote } from './Cards';
// Ask for a policy change in English; read the diff; decide.
//
// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners,
// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to
// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor
// above and the existing Save button is still the only thing that leaves the browser.
//
// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows
// exactly what accepting would change on screen — including edits the owner made and hasn't saved.
const EXAMPLES = [
'let everyone reach the machines tagged tag:server on port 22',
'stop the phones from reaching anything except the DNS server',
'add a group for family with just my own user in it',
];
const DiffBody = ({ before, after }: { before: string; after: string }) => {
const lines = useMemo(() => diffLines(before, after), [before, after]);
const rows = useMemo(() => collapseUnchanged(lines), [lines]);
const { added, removed } = useMemo(() => diffCounts(lines), [lines]);
if (!added && !removed) {
return <p className="px-3 py-2.5 text-[11px] text-zinc-500">No change the proposal matches what you have.</p>;
}
return (
<>
<div className="flex items-center gap-3 border-b border-white/10 px-3 py-1.5 text-[11px]">
<span className="text-emerald-400">+{added}</span>
<span className="text-red-400">{removed}</span>
<span className="text-zinc-600">unchanged lines collapsed</span>
</div>
<div className="max-h-72 overflow-auto p-1 font-mono text-[11px] leading-relaxed">
{rows.map((row, index) =>
row === null ? (
<div key={index} className="px-2 py-1 text-center text-zinc-700 select-none">
</div>
) : (
<div
key={index}
className={`px-2 whitespace-pre-wrap ${
row.kind === 'add'
? 'bg-emerald-500/10 text-emerald-300'
: row.kind === 'remove'
? 'bg-red-500/10 text-red-300'
: 'text-zinc-500'
}`}
>
{row.kind === 'add' ? '+' : row.kind === 'remove' ? '' : ' '} {row.text}
</div>
),
)}
</div>
</>
);
};
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 (
<Card>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-zinc-200">Describe the change</span>
<span className="ml-auto text-[11px] text-zinc-600">Proposes a document never saves it</span>
</div>
<div className="flex flex-col gap-2 p-3">
<textarea
value={prompt}
onChange={(ev) => setPrompt(ev.target.value)}
onKeyDown={(ev) => {
// Enter sends: this is a one-line instruction far more often than a paragraph, and shift-enter
// is still there for the times it isn't.
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
void ask();
}
}}
rows={2}
spellCheck={false}
disabled={disabled}
placeholder="e.g. give my laptop SSH access to everything tagged tag:server"
className="w-full resize-y rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm leading-relaxed text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50 disabled:opacity-50"
/>
<div className="flex flex-wrap items-center gap-2">
{!prompt.trim() &&
EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => setPrompt(example)}
disabled={disabled}
className="cursor-pointer rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-500 transition-colors hover:border-white/20 hover:text-zinc-300 disabled:opacity-40"
>
{example}
</button>
))}
<div className="ml-auto">
<Button variant="primary" onClick={() => void ask()} disabled={!prompt.trim() || assist.isPending}>
{assist.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{assist.isPending ? 'Drafting…' : 'Ask'}
</Button>
</div>
</div>
{assist.error && <ErrorNote>{assistFailure(assist.error)}</ErrorNote>}
</div>
{proposal && (
<div className="border-t border-white/10">
{proposal.explanation && (
<p className="px-3 py-2.5 text-xs leading-relaxed whitespace-pre-wrap text-zinc-300">
{proposal.explanation}
</p>
)}
<div className="border-t border-white/10">
<DiffBody before={policy} after={proposal.policy} />
</div>
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2">
<span className="text-[11px] text-zinc-600">Applying only fills the editor you still press Save.</span>
<span className="ml-auto flex items-center gap-2">
<Button onClick={() => setProposal(null)}>
<X className="h-3.5 w-3.5" />
Discard
</Button>
<Button variant="primary" onClick={apply}>
<Check className="h-3.5 w-3.5" />
Apply to editor
</Button>
</span>
</div>
</div>
)}
</Card>
);
};
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
import { timeAgo } from './format';
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
import { PolicyAssistant } from './PolicyAssistant';
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
//
// It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach
// which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a
// way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what
// this file should look like" is the actual reason this screen was hard to use.
//
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
//
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
function useSaveShortcut(onSave: () => void, enabled: boolean) {
const handler = useRef(onSave);
handler.current = onSave;
useEffect(() => {
if (!enabled) return;
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
handler.current();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
const ReadOnlyBanner = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
way in) and reload it there. Everything below is still the live document, and still readable.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
// and re-discovering that by pressing save again is not information.
const [readOnly, setReadOnly] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
// reach in and replace what someone is typing.
const text = draft ?? policy?.policy ?? '';
const dirty = draft !== null && draft !== (policy?.policy ?? '');
const submit = async () => {
if (!dirty || readOnly || save.isPending) return;
setFailure(null);
try {
await save.mutateAsync(text);
setDraft(null);
setSavedAt(Date.now());
// A clean save is the end of the edit, not the start of the next one — back to reading.
setEditing(false);
} catch (err) {
const parsed = policySaveFailure(err);
setFailure(parsed);
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
}
};
useSaveShortcut(() => void submit(), editing && dirty && !readOnly);
const revert = () => {
setDraft(null);
setFailure(null);
};
/** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */
const stopEditing = () => {
revert();
setEditing(false);
};
return (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
editing ? (
<div className="flex items-center gap-2">
<Button onClick={stopEditing} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
{dirty ? 'Discard' : 'Done'}
</Button>
<Button
variant="primary"
onClick={() => void submit()}
disabled={!dirty || !!readOnly || save.isPending}
>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
) : (
<Button
onClick={() => setEditing(true)}
disabled={!!readOnly}
title={readOnly ? 'This server will not accept written policies' : undefined}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
{editing && (
<PolicyAssistant
policy={text}
onApply={(proposed) => {
setDraft(proposed);
setFailure(null);
setSavedAt(null);
}}
disabled={save.isPending}
/>
)}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
readOnly={!editing}
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
}`}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
Headscale checks that the document is valid, not that it is what you meant.
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
</p>
</div>
</ViewShell>
);
};
+226
View File
@@ -0,0 +1,226 @@
import { useState } from 'react';
import { Loader2, Terminal, Check, X } from 'lucide-react';
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleSshTest, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, Button, Field, ErrorNote } from './Cards';
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
// a pending state saying what is being checked, and the server's own reason inline on rejection.
//
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
//
// The SSH host is the odd one out: it is NOT validated on save. A control server that is down is exactly when
// you want the console, so refusing to save the escape hatch because the machine is unreachable would be
// precisely backwards. Test is a separate, explicit button.
/** The host part of the control-server URL, for the "you have typed the same machine" warning. */
function urlHost(url: string): string | null {
try {
return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase();
} catch {
return null;
}
}
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const { register, update } = useHeadscaleServers();
const sshTest = useHeadscaleSshTest();
const editing = !!server;
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
const [apiKey, setApiKey] = useState('');
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
const [error, setError] = useState<string | null>(null);
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
const mutation = editing ? update : register;
const pending = mutation.isPending;
// A rejection leaves its reason under the button, and the reason is about values that have since been
// corrected. Editing anything clears it, so a stale message can never make a live form look dead.
const edit =
<T,>(set: (value: T) => void) =>
(value: T) => {
set(value);
setError(null);
};
// The point of a separate SSH address is reaching the box when the tailnet or Headscale itself is down. If
// it resolves through the same name the control server does, it goes down with it — which is the one thing
// this field is supposed to survive.
const sameAsControl = !!sshHost.trim() && !!urlHost(url) && sshTarget(sshHost) === urlHost(url);
const runSshTest = async () => {
setSshResult(null);
try {
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
} catch (err) {
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
}
};
const submit = async () => {
if (pending) return;
setError(null);
// Drop the previous rejection from the mutation too — this is a fresh attempt, not a retry of that one.
mutation.reset();
if (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required');
try {
if (editing && server) {
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
await update.mutateAsync({
id: server.id,
name: name.trim() || undefined,
url: url.trim() === server.url ? undefined : url.trim(),
apiKey: apiKey.trim() || undefined,
// '' is meaningful here — it clears the console target — so this is sent whenever it differs.
sshHost: sshHost.trim() === (server.sshHost ?? '') ? undefined : sshHost.trim(),
});
} else {
await register.mutateAsync({
name: name.trim() || undefined,
url: url.trim(),
apiKey: apiKey.trim(),
sshHost: sshHost.trim() || undefined,
});
}
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
</div>
<Field
label="Server URL"
value={url}
onChange={edit(setUrl)}
placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
autoFocus={!editing}
/>
<Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey}
onChange={edit(setApiKey)}
type="password"
placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
/>
<Field
label="Name (optional)"
value={name}
onChange={edit(setName)}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-3">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-300">
<Terminal className="h-3.5 w-3.5" />
SSH console (optional)
</div>
<p className="text-[11px] leading-snug text-zinc-500">
The last resort for when the API cannot answer Headscale crashed, the tailnet is down, the logs are the
only evidence. The Console section runs plain <code className="font-mono">ssh</code> here in a terminal,
using the keys already on this machine. Officer stores no password, key or port.
</p>
<Field
label="SSH address"
value={sshHost}
onChange={edit((value: string) => {
setSshHost(value);
setSshResult(null);
})}
placeholder="203.0.113.10 or root@203.0.113.10"
hint="Use the machine's own address, not the Headscale hostname. Leave blank for no console."
/>
{sameAsControl && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
That is the same host as the server URL. If Headscale is what resolves or routes that name, the console
will be unreachable in exactly the situations you would need it. Prefer the machine's raw IP on a path
that does not depend on the tailnet.
</div>
)}
{sshResult && (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[11px] leading-snug ${
sshResult.ok
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/30 bg-red-500/10 text-red-300'
}`}
>
{sshResult.ok ? (
<Check className="mt-px h-3.5 w-3.5 shrink-0" />
) : (
<X className="mt-px h-3.5 w-3.5 shrink-0" />
)}
<span>
{sshResult.ok ? (
<>Connected and ran a command in {sshResult.ms}ms.</>
) : (
<>
{sshResult.error ?? 'Could not connect'}
<span className="mt-1 block text-red-300/70">
The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails
here as “Permission denied”.
</span>
</>
)}
</span>
</div>
)}
<div>
<Button onClick={() => void runSshTest()} disabled={!sshHost.trim() || sshTest.isPending}>
{sshTest.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Terminal className="h-3.5 w-3.5" />
)}
{sshTest.isPending ? 'Connecting' : 'Test connection'}
</Button>
</div>
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={pending}>
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{pending ? 'Verifying' : editing ? 'Save changes' : 'Register server'}
</Button>
<Button onClick={onClose} disabled={pending}>
Cancel
</Button>
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key</span>}
</div>
</form>
</Card>
);
};
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useRef, useState } from 'react';
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
import type { HeadscaleServer, HeadscaleHealth } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
import { ServerForm } from './ServerForm';
// The servers section — register Headscale servers and switch between them. Exactly one is active at a
// time (a DB invariant, not a UI convention), and every other section in this workspace reads it.
//
// EVERY server is probed when this section opens, in parallel, and again for any server registered while
// it is open. A probe costs two upstream round trips (an unauthenticated /version plus an authenticated
// call to prove the stored key still works) — cheap enough at this scale, and the alternative was worse:
// a grey "not checked" dot is the one thing this list must never show, because the reason to look at it
// is to find out which servers are up. A dot that says nothing makes the whole page say nothing.
function timeAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
type ServerRowProps = {
server: HeadscaleServer;
health: HeadscaleHealth | undefined;
testing: boolean;
busy: boolean;
onActivate: () => void;
onTest: () => void;
onEdit: () => void;
onRemove: () => void;
};
const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => {
const [confirming, setConfirming] = useState(false);
// Amber means "asking"; grey should only ever be the frame before the automatic probe starts.
const tone = health ? (health.ok ? 'ok' : 'bad') : testing ? 'warn' : 'idle';
return (
<Card>
<div className="flex flex-col gap-3 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-1.5">
<Dot tone={tone} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
{server.isActive && <Badge tone="active">Active</Badge>}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
{server.url}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
</div>
</div>
</div>
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
{health?.ok && health.supported === 'unknown' && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
This server reports its version as {health.version}, so Officer cannot confirm it is{' '}
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
builds.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{!server.isActive && (
<Button variant="primary" onClick={onActivate} disabled={busy}>
<Check className="h-3.5 w-3.5" />
Use this server
</Button>
)}
<Button onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
{testing ? 'Testing…' : 'Test'}
</Button>
<Button onClick={onEdit} disabled={busy}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={onRemove} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
</Card>
);
};
const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<Server className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
<p className="mt-1 text-sm text-zinc-500">
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
supports Headscale {MIN_HEADSCALE_VERSION} and newer.
</p>
</div>
<Button variant="primary" onClick={onRegister}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
</div>
);
export const ServersView = () => {
const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const test = async (id: number) => {
setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setActionError(null);
try {
const result = await healthProbe.mutateAsync(id);
setHealth((prev) => ({ ...prev, [id]: result }));
} catch (err) {
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
// page-level error, which would blame the whole screen for one unreachable box.
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
} finally {
setTestingIds((prev) => prev.filter((t) => t !== id));
}
};
// Probe every server once per visit to this section, and any server that appears while it is open. The
// ref is what makes "once" true: the list identity changes when a probe writes lastSeenAt, and without
// it each result would trigger the next round forever.
const probed = useRef(new Set<number>());
useEffect(() => {
for (const server of servers) {
if (probed.current.has(server.id)) continue;
probed.current.add(server.id);
void test(server.id);
}
}, [servers]);
const run = async (fn: () => Promise<unknown>) => {
setActionError(null);
try {
await fn();
} catch (err) {
setActionError(headscaleErrorMessage(err));
}
};
const busy = activate.isPending || remove.isPending;
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<SectionHeader
title="Servers"
subtitle="One server is active at a time; every other section acts on it."
action={
!formFor && (
<Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
)
}
/>
{/* A failed list fetch is reported here, NOT as a replacement for the whole section. It used to be
an early return, which unmounted the form mid-registration and threw away everything typed into
it — leaving a reload as the only way to try again. Nothing on this screen may take the form
off the page except the owner. */}
{error && (
<ErrorNote>
<div className="flex flex-wrap items-center gap-2">
<span>
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
</span>
<button
type="button"
onClick={() => void refetch()}
className="ml-auto shrink-0 cursor-pointer rounded-lg border border-red-500/30 px-2 py-1 font-medium transition-colors hover:bg-red-500/20"
>
Try again
</button>
</div>
</ErrorNote>
)}
{/* Keyed by which server it edits: the form seeds its fields from the prop once, at mount, so
switching straight from one server's Edit to another's would otherwise keep the first one's
values — and submit diffs those stale values against the NEW server, writing them to it. */}
{formFor && (
<ServerForm
key={formFor === 'new' ? 'new' : formFor.id}
server={formFor === 'new' ? null : formFor}
onClose={() => setFormFor(null)}
/>
)}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{isLoading && (
<div className="flex items-center justify-center py-16 text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
)}
{/* Empty state doubles as the registration prompt — there is nothing else to do here without a
server. Hidden while the form is open, because it is then the same offer twice. */}
{!isLoading && !error && servers.length === 0 && !formFor && (
<EmptyState onRegister={() => setFormFor('new')} />
)}
{servers.map((server) => (
<ServerRow
key={server.id}
server={server}
health={health[server.id]}
testing={testingIds.includes(server.id)}
busy={busy}
onActivate={() => void run(() => activate.mutateAsync(server.id))}
onTest={() => void test(server.id)}
onEdit={() => setFormFor(server)}
onRemove={() => void run(() => remove.mutateAsync(server.id))}
/>
))}
</div>
</div>
);
};
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
import type { HeadscaleUserWithCounts } from './shared';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo } from './format';
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
//
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
const UserRow = ({ user, onError }: UserRowProps) => {
const { rename, remove } = useHeadscaleUsers();
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(user.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draft.trim();
setRenaming(false);
if (!name || name === user.name) return;
await run(() => rename.mutateAsync({ id: user.id, name }));
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => setDraft(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"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
if (!name.trim()) return setError('A name is required');
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { Loader2, ServerOff } from 'lucide-react';
import { NO_ACTIVE_SERVER } from './shared';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { ErrorNote } from './Cards';
// The loading / no-server / failed states every domain section shares.
//
// "No active server" is a 409 carrying a `code`, deliberately not a 404 and deliberately not an empty
// list — an empty node table would read as "your tailnet is empty", which is a very different and much
// more alarming statement than "you haven't picked a server".
function isNoActiveServer(err: unknown): boolean {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string') return false;
try {
return (JSON.parse(raw) as { code?: unknown }).code === NO_ACTIVE_SERVER;
} catch {
return false;
}
}
type ViewShellProps = {
isLoading: boolean;
error: unknown;
/** What this section is called, for the loading and empty copy. */
label: string;
children: ReactNode;
};
export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps) => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading {label}
</div>
);
}
if (error && isNoActiveServer(error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<ServerOff className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to see its {label}.</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not load {label}: {headscaleErrorMessage(error)}
</ErrorNote>
</div>
);
}
return <div className="h-full overflow-y-auto p-4">{children}</div>;
};
/** Centred "nothing here yet" body for a section whose fetch succeeded but returned nothing. */
export const EmptyBody = ({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) => (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">{icon}</div>
<div>
<div className="text-base font-semibold text-zinc-100">{title}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">{hint}</p>
</div>
</div>
);
+77
View File
@@ -0,0 +1,77 @@
// A line diff, for showing what a proposed policy actually changes before anyone saves it.
//
// Hand-rolled rather than a dependency: this is one screen showing one document, the inputs are a few
// hundred lines at most, and the alternative is adding a package to the frozen lockfile for forty lines of
// well-understood algorithm. If a second surface ever needs a diff, that trade flips.
export type DiffLine = { kind: 'same' | 'add' | 'remove'; text: string };
/** Longest common subsequence table over the two line arrays. O(n·m) — fine at document scale. */
function lcsLengths(a: string[], b: string[]): number[][] {
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
}
}
return table;
}
/** Every line of both documents, in order, tagged with what happened to it. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n');
const b = after.split('\n');
const table = lcsLengths(a, b);
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
out.push({ kind: 'same', text: a[i]! });
i++;
j++;
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
out.push({ kind: 'remove', text: a[i]! });
i++;
} else {
out.push({ kind: 'add', text: b[j]! });
j++;
}
}
while (i < a.length) out.push({ kind: 'remove', text: a[i++]! });
while (j < b.length) out.push({ kind: 'add', text: b[j++]! });
return out;
}
/**
* Drop long runs of unchanged lines, keeping `context` either side of every change.
*
* A policy is mostly unchanged by any one edit, and an unabridged diff buries the three lines that matter.
* `null` marks each elision so the view can draw a gap rather than pretend the lines are adjacent.
*/
export function collapseUnchanged(lines: DiffLine[], context = 3): (DiffLine | null)[] {
const keep = new Array<boolean>(lines.length).fill(false);
lines.forEach((line, index) => {
if (line.kind === 'same') return;
for (let k = Math.max(0, index - context); k <= Math.min(lines.length - 1, index + context); k++) keep[k] = true;
});
const out: (DiffLine | null)[] = [];
let gap = false;
lines.forEach((line, index) => {
if (keep[index]) {
out.push(line);
gap = false;
} else if (!gap) {
out.push(null);
gap = true;
}
});
return out;
}
export const diffCounts = (lines: DiffLine[]) => ({
added: lines.filter((l) => l.kind === 'add').length,
removed: lines.filter((l) => l.kind === 'remove').length,
});
+35
View File
@@ -0,0 +1,35 @@
// Date formatting for the Headscale views. The sidecar already turned protobuf's zero timestamp into null,
// so null genuinely means "never" here and every helper says so rather than printing a fake date.
export function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return 'just now';
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 365) return `${days}d ago`;
return `${Math.round(days / 365)}y ago`;
}
/** "in 3d" / "5h ago" — signed, for expiry dates that may be either side of now. */
export function timeUntil(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return timeAgo(iso);
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `in ${Math.max(1, minutes)}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(hours / 24)}d`;
}
export function fullDate(iso: string | null): string {
if (!iso) return 'never';
return new Date(iso).toLocaleString();
}
+22
View File
@@ -0,0 +1,22 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'offscale-root',
direction: 'horizontal',
children: [
{
node: {
type: 'group',
id: 'headscale-sidebar',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
],
},
size: 22,
},
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
],
};
+27
View File
@@ -0,0 +1,27 @@
import type { AppRegistryMeta } from 'officerdev';
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
import { HeadscaleView } from './HeadscaleView';
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
export { HeadscaleNav, HeadscaleServerPicker, HeadscaleView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'headscale-servers',
name: 'Headscale servers',
icon: Network,
component: HeadscaleServerPicker,
availableOnPanel: false,
},
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
{
key: 'headscale-view',
name: 'Headscale',
icon: LayoutGrid,
component: HeadscaleView,
header: HeadscaleViewHeader,
availableOnPanel: false,
},
];
+225
View File
@@ -0,0 +1,225 @@
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts.
export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'invites', label: 'Device invites' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
HEADSCALE_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
/**
* Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
* the machine when the control plane's own hostname has stopped answering.
*/
sshHost: string | null;
isActive: boolean;
/** ISO string, or null when we have never successfully probed it. */
lastSeenAt: string | null;
createdAt: string;
};
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
export type HeadscaleHealth = {
ok: boolean;
version?: string;
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
supported?: boolean | 'unknown';
error?: string;
ms: number;
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
/**
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
* because there is nothing on the server that reports one. Only an attempted save finds out.
*/
export type HeadscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
};
/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
/** Never available for a companion that is missing — the reason says which flavour of missing. */
type Unavailable = { available: false; reason: string };
export type CompanionVerdict = 'ok' | 'degraded' | 'down' | 'unknown';
export type CompanionContainer = {
status: string;
running: boolean;
exitCode: number;
restartCount: number;
startedAt: string;
/** The RFC3339 zero date (`0001-…`) while the container is running. */
finishedAt: string;
/** Null when the image defines no healthcheck. */
healthcheck: string | null;
};
export type CompanionHealthBody = {
verdict: CompanionVerdict;
/** Whether Headscale's own HTTP is answering — the "is the control plane serving?" signal. */
connected: boolean;
container?: CompanionContainer;
/** Human string for the probe outcome, e.g. `GET /health -> 200`, `unreachable`, `container not running`. */
probe?: string;
/** Only on `unknown`: docker has no container by that name. */
reason?: string;
/** Only when not ok — best-effort guesses read out of the logs. May be empty. */
likelyCauses?: string[];
healthcheckOutput?: string | null;
recentLogs?: string[];
};
export type CompanionHealthResult = ({ available: true } & { health: CompanionHealthBody }) | Unavailable;
export type CompanionLogsResult = { available: true; lines: string[] } | Unavailable;
/** The three lifecycle verbs. `stop`/`start` are what the companion calls `disconnect`/`reconnect`. */
export type CompanionAction = 'restart' | 'stop' | 'start';
export type CompanionActionResult =
| { available: true; ok: boolean; action?: string; result?: string; error?: string }
| Unavailable;
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type HeadscaleUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
export type HeadscaleNode = {
id: string;
name: string;
hostname: string;
user: HeadscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** Null means the node's key never expires. */
expiry: string | null;
createdAt: string | null;
registerMethod: string;
tags: string[];
/** What the node advertises. */
availableRoutes: string[];
/** What the admin has approved — the writable set. */
approvedRoutes: string[];
/** What is actually in effect. */
subnetRoutes: string[];
isExitNode: boolean;
};
export type HeadscalePreAuthKey = {
id: string;
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
key: string | null;
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: HeadscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
status: 'active' | 'used' | 'expired';
};
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
export type HeadscaleInvite = {
id: string;
user: string;
note?: string | null;
status: InviteStatus;
ephemeral?: boolean;
tags?: string[];
createdAt?: string | null;
expiresAt?: string | null;
claimedAt?: string | null;
claimedFromIp?: string | null;
};
/**
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
* component state, shown once, and never written to a cache, a query key or a log.
*/
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note: string;
};
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
export const INVITE_TTL_MIN_SECONDS = 60;
export const INVITE_TTL_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient, getHeaders } from 'hooks/useClient';
import type { CompanionAction, CompanionActionResult, CompanionHealthResult, CompanionLogsResult } from './shared';
// Client for the active server's Officer Companion. Everything here goes through the headscale sidecar,
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
// decryptable only there. The browser never sees it and never talks to the companion directly.
const BASE = '/offscale/_officer/companion';
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
/**
* The container's health, polled.
*
* Polling is the point rather than a convenience: this section is what you have open while waiting for a
* restart to take, so it has to move on its own. 10s is fast enough to watch a container come back and slow
* enough that a degraded server isn't being hammered while it struggles.
*/
export function useCompanionHealth() {
const { get } = useClient();
return useQuery({
queryKey: HEALTH_KEY,
queryFn: () => get<CompanionHealthResult>(`${BASE}/health`),
refetchInterval: 10_000,
// A verdict from ten seconds ago is stale by definition; never show one on remount without refetching.
staleTime: 0,
});
}
/** A snapshot of the last N lines. The live tail is a separate thing — see useCompanionLogStream. */
export function useCompanionLogs(tail: number, enabled: boolean) {
const { get } = useClient();
return useQuery({
queryKey: ['headscale', 'companion', 'logs', tail],
queryFn: () => get<CompanionLogsResult>(`${BASE}/logs?tail=${tail}`),
enabled,
staleTime: 0,
});
}
/**
* Restart / stop / start the Headscale container.
*
* Every one of these drops every node's control-plane connection, so nothing here retries and nothing here
* fires without the owner having confirmed. The health query is invalidated on settle — including on
* failure, where "did it happen anyway?" is exactly the question.
*/
export function useCompanionAction() {
const { post } = useClient();
const qc = useQueryClient();
return useMutation({
mutationFn: (action: CompanionAction) => post<CompanionActionResult>(`${BASE}/${action}`),
onSettled: () => qc.invalidateQueries({ queryKey: HEALTH_KEY }),
});
}
/** How many lines the viewer keeps. Beyond this the browser, not the server, becomes the bottleneck. */
const MAX_LINES = 5000;
export type LogStream = {
lines: string[];
/** Set when the stream ended badly — the companion's own `event: error` frame, or a dropped connection. */
error: string | null;
/** True between opening the request and the stream ending, however it ends. */
live: boolean;
clear: () => void;
};
/**
* The live log tail, over `fetch()` rather than `EventSource`.
*
* `EventSource` cannot send an `Authorization` header and every hop of this chain needs one — Officer's own
* bearer token to reach the platform, and the Headscale admin key from there on. So the SSE framing is
* parsed by hand: split on blank lines, read `data:` and `event:`. It is a small parser and it only has to
* handle what the companion emits (one line per frame, an `event: error` frame before a fatal close).
*
* `follow` changing off aborts mid-stream; the abort is deliberately not reported as an error, since it is
* the owner switching the toggle rather than anything going wrong.
*/
export function useCompanionLogStream(follow: boolean, tail: number): LogStream {
const [lines, setLines] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState(false);
// Batched through a ref: a busy container emits faster than React can render, and one setState per line
// would spend the whole frame budget on log output.
const pending = useRef<string[]>([]);
useEffect(() => {
if (!follow) return;
const controller = new AbortController();
let flushTimer: ReturnType<typeof setInterval> | null = null;
setError(null);
setLive(true);
const flush = () => {
if (pending.current.length === 0) return;
const batch = pending.current;
pending.current = [];
setLines((prev) => {
const next = prev.concat(batch);
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
});
};
const run = async () => {
try {
const res = await fetch(`/api${BASE}/logs/stream?tail=${tail}`, {
headers: getHeaders(),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setError(`the log stream returned ${res.status}`);
return;
}
flushTimer = setInterval(flush, 200);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Frames are separated by a blank line; anything after the last one is a partial frame and waits.
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let event = 'message';
const data: string[] = [];
for (const rawLine of frame.split('\n')) {
if (rawLine.startsWith('event:')) event = rawLine.slice(6).trim();
else if (rawLine.startsWith('data:')) data.push(rawLine.slice(5).replace(/^ /, ''));
}
if (data.length === 0) continue;
const text = data.join('\n');
if (event === 'error') setError(text);
else pending.current.push(text);
}
}
} catch (err) {
if ((err as Error | null)?.name !== 'AbortError') setError('the log stream disconnected');
} finally {
if (flushTimer) clearInterval(flushTimer);
flush();
// An aborted stream is already being torn down by the effect that replaced this one; letting it set
// state here would flash "not live" onto a stream that is about to reopen.
if (!controller.signal.aborted) setLive(false);
}
};
void run();
return () => {
controller.abort();
if (flushTimer) clearInterval(flushTimer);
setLive(false);
};
}, [follow, tail]);
return { lines, error, live, clear: () => setLines([]) };
}
+163
View File
@@ -0,0 +1,163 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared';
// Queries for the domain sections. All three act on whichever server is active, so they live under the
// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers).
//
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
// are small and the correctness is worth more than the round trip.
const NODES_KEY = ['headscale', 'nodes'] as const;
const USERS_KEY = ['headscale', 'users'] as const;
const KEYS_KEY = ['headscale', 'keys'] as const;
const EMPTY_NODES: HeadscaleNode[] = [];
const EMPTY_USERS: HeadscaleUserWithCounts[] = [];
const EMPTY_KEYS: HeadscalePreAuthKey[] = [];
export function useHeadscaleNodes() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: NODES_KEY,
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
refetchInterval: 20_000,
staleTime: 10_000,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
onSuccess: invalidate,
});
const setTags = useMutation({
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
onSuccess: invalidate,
});
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
const moveToUser = useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
onSuccess: invalidate,
});
// Single-route toggle: the sidecar reads the current approved set and writes it back with one change,
// because Headscale's approve_routes replaces the whole set.
const toggleRoute = useMutation({
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
onSuccess: invalidate,
});
return {
nodes: query.data?.nodes ?? EMPTY_NODES,
isLoading: query.isLoading,
error: query.error,
rename,
setTags,
moveToUser,
toggleRoute,
expire,
remove,
};
}
export function useHeadscaleUsers() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: USERS_KEY,
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
post('/offscale/_officer/users', input),
onSuccess: invalidate,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
onSuccess: invalidate,
});
return {
users: query.data?.users ?? EMPTY_USERS,
isLoading: query.isLoading,
error: query.error,
create,
rename,
remove,
};
}
export type CreateKeyInput = {
userId: string;
reusable: boolean;
ephemeral: boolean;
expirationDays: number;
aclTags: string[];
};
export function useHeadscaleKeys() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
const query = useQuery({
queryKey: KEYS_KEY,
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
staleTime: 30_000,
});
// The response carries the only copy of the secret that will ever exist. It is returned to the caller
// (not merged into the list cache) so the view can show it once and deliberately drop it.
const create = useMutation({
mutationFn: (input: CreateKeyInput) =>
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
onSuccess: invalidate,
});
return {
keys: query.data?.keys ?? EMPTY_KEYS,
isLoading: query.isLoading,
error: query.error,
create,
expire,
remove,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the
// server's own companion — see src/servers/sidecar/headscale/invites.ts for why the records live there and
// not here.
//
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
// drops it. The list is refetched instead, which returns the same invite without its token.
const BASE = '/offscale/_officer/enroll/invites';
const INVITES_KEY = ['headscale', 'invites'] as const;
const EMPTY: InvitesListResult = { available: true, invites: [] };
export function useHeadscaleInvites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
const query = useQuery({
queryKey: INVITES_KEY,
queryFn: () => get<InvitesListResult>(BASE),
// A pending invite expires on a clock, so a list left open goes wrong on its own. Cheap: one companion
// call against a table with a handful of rows.
refetchInterval: 30_000,
staleTime: 10_000,
});
const create = useMutation({
mutationFn: async (input: InviteCreateInput): Promise<HeadscaleInviteCreated> => {
const result = await post<InviteCreateResult>(BASE, input);
// An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the
// form shows it where the admin is looking rather than rendering an empty link panel.
if (!result.available) throw new Error(result.reason);
return result.invite;
},
onSuccess: invalidate,
});
const revoke = useMutation({
mutationFn: (id: string) => del(`${BASE}/${encodeURIComponent(id)}`),
onSuccess: invalidate,
});
const result = query.data ?? EMPTY;
return {
invites: result.available ? result.invites : [],
/** Set when this server has no enrolment API — a state to explain, not an error. */
unavailable: result.available ? null : result.reason,
isLoading: query.isLoading,
error: query.error,
create,
revoke,
};
}
+79
View File
@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscalePolicy } from './shared';
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
const POLICY_KEY = ['headscale', 'policy'] as const;
const PATH = '/offscale/_officer/policy';
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
/** useClient throws `{status, message}` with the raw body text — dig the sidecar's `{error, code}` out. */
export function policySaveFailure(err: unknown): PolicySaveFailure {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return { kind: 'unknown', message: 'The policy could not be saved' };
try {
const parsed = JSON.parse(raw) as { error?: unknown; code?: unknown };
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The policy could not be saved';
if (parsed.code === POLICY_READ_ONLY) return { kind: 'readOnly', message };
if (parsed.code === POLICY_REJECTED) return { kind: 'rejected', message };
return { kind: 'unknown', message };
} catch {
return { kind: 'unknown', message: raw.slice(0, 300) };
}
}
/** What the assistant proposes. Never saved by the hook — PolicyView puts it in the draft. */
export type PolicyProposal = { explanation: string; policy: string };
/** The assistant's failures are all one sentence to the owner; only the sidecar's `error` field is useful. */
export function assistFailure(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'The assistant could not be reached';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
return typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The assistant could not be reached';
} catch {
return raw.slice(0, 300);
}
}
/**
* Ask for a revised policy in English. Separate from `useHeadscalePolicy` because it is a different kind of
* thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload.
*/
export function useHeadscalePolicyAssist() {
const { post } = useClient();
return useMutation({
mutationFn: (input: { prompt: string; policy: string }) => post<PolicyProposal>(`${PATH}/assist`, input),
});
}
export function useHeadscalePolicy() {
const { get, put } = useClient();
const qc = useQueryClient();
const query = useQuery({
queryKey: POLICY_KEY,
queryFn: () => get<HeadscalePolicy>(PATH),
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
const save = useMutation({
mutationFn: (policy: string) => put<HeadscalePolicy>(PATH, { policy }),
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
// own state and could show the pre-save document for a frame.
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
});
return { policy: query.data ?? null, isLoading: query.isLoading, error: query.error, refetch: query.refetch, save };
}
+12
View File
@@ -0,0 +1,12 @@
import { useParams } from 'react-router';
import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
// fallback here is only for the instant before that lands.
export function useHeadscaleSection(): HeadscaleSectionId {
const { section } = useParams();
return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
}
+102
View File
@@ -0,0 +1,102 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './shared';
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
// the active server anywhere updates the whole screen at once.
//
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
// exceptional here, so the mutations surface their message rather than swallowing it.
const SERVERS_KEY = ['headscale', 'servers'] as const;
const EMPTY: HeadscaleServer[] = [];
const BASE = '/offscale/_officer/servers';
/**
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
*/
export function headscaleErrorMessage(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch {
/* plain text */
}
return raw.slice(0, 300);
}
export type RegisterServerInput = { name?: string; url: string; apiKey: string; sshHost?: string };
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: string };
export function useHeadscaleServers() {
const { get, post, patch, delete: del } = useClient();
const qc = useQueryClient();
const query = useQuery({
queryKey: SERVERS_KEY,
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
staleTime: 30_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
const register = useMutation({
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => del(`${BASE}/${id}`),
onSuccess: invalidate,
});
const activate = useMutation({
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
});
const servers = query.data?.servers ?? EMPTY;
return {
servers,
active: servers.find((s) => s.isActive) ?? null,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
register,
update,
remove,
activate,
};
}
/**
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
*/
export function useHeadscaleSshTest() {
const { post } = useClient();
return useMutation({
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
});
}
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
export function useHeadscaleHealth() {
const { get } = useClient();
return useMutation({
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
});
}