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:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 1–2000');
|
||||
|
||||
const res = await callCompanion(creds, { path: `/logs?tail=${tail}` });
|
||||
if (typeof res === 'string') return Response.json(unavailable(res));
|
||||
|
||||
const body = await readBody(res);
|
||||
if (typeof body === 'string') return Response.json(unavailable(body));
|
||||
const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : [];
|
||||
return Response.json({ available: true, lines });
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame.
|
||||
*
|
||||
* The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need
|
||||
* is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a
|
||||
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
|
||||
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
|
||||
*/
|
||||
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
|
||||
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000');
|
||||
|
||||
// No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's
|
||||
// own signal is the lifetime — when the panel closes, this closes.
|
||||
const res = await callCompanion(creds, {
|
||||
path: `/logs?tail=${tail}&follow=1`,
|
||||
signal: ctx.req.signal,
|
||||
});
|
||||
|
||||
// An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and
|
||||
// one place to show a problem rather than a second, JSON-shaped failure mode.
|
||||
if (typeof res === 'string') {
|
||||
return new Response(`event: error\ndata: ${res}\n\n`, {
|
||||
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
// Belt and braces through our own proxy chain, matching what the companion already sets.
|
||||
'x-accel-buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIONS = new Set(['restart', 'stop', 'start']);
|
||||
|
||||
/**
|
||||
* `POST /_officer/companion/:action` — restart / stop / start the Headscale container.
|
||||
*
|
||||
* Every one of these drops every node's control-plane connection for the duration. That is the intended
|
||||
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
|
||||
*/
|
||||
async function action(creds: HeadscaleServerCredentials, name: string): Promise<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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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
@@ -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] });
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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>[]) : [];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Headscale version detection and the supported floor.
|
||||
//
|
||||
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
|
||||
// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at
|
||||
// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth
|
||||
// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.23–0.28 would mean
|
||||
// carrying several incompatible data models; refusing them at registration time costs one probe.
|
||||
//
|
||||
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
|
||||
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
|
||||
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
|
||||
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
|
||||
|
||||
export const MIN_MAJOR = 0;
|
||||
export const MIN_MINOR = 29;
|
||||
export const MIN_VERSION_LABEL = '0.29';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
export type VersionProbe =
|
||||
| { ok: true; version: string; supported: true }
|
||||
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
|
||||
| { ok: true; version: string; supported: 'unknown' }
|
||||
| { ok: true; version: string; supported: false }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
|
||||
export function parseVersion(raw: string): { major: number; minor: number } | null {
|
||||
const m = raw
|
||||
.trim()
|
||||
.replace(/^v/, '')
|
||||
.match(/^(\d+)\.(\d+)/);
|
||||
if (!m) return null;
|
||||
return { major: Number(m[1]), minor: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** Whether a parsed version is at or above the supported floor. */
|
||||
export function meetsFloor(v: { major: number; minor: number }): boolean {
|
||||
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
|
||||
return v.minor >= MIN_MINOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
|
||||
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
|
||||
*
|
||||
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
|
||||
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
|
||||
*/
|
||||
export async function probeVersion(baseUrl: string): Promise<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
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 60s–24h range by construction. */
|
||||
const TTL_OPTIONS = [
|
||||
{ seconds: 300, label: '5 minutes' },
|
||||
{ seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' },
|
||||
{ seconds: 3600, label: '1 hour' },
|
||||
{ seconds: 86_400, label: '24 hours' },
|
||||
] as const;
|
||||
|
||||
const CopyButton = ({ value, label }: { value: string; label: string }) => {
|
||||
const [done, setDone] = useState(false);
|
||||
const copy = () => {
|
||||
void copyToClipboard(value);
|
||||
setDone(true);
|
||||
window.setTimeout(() => setDone(false), 1500);
|
||||
};
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
@@ -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([]) };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user