pastilhasandClaude Opus 5 373d67e0a0 finish the rebrand: the parts a user reads, and the manifest that was wrong
The extraction landed months ago and the platform side is clean. What was left was
inside the plugin, and it was the half that shows.

── Names a user actually reads ──

The three registered panels were still called "Headscale servers", "Headscale" and
"Headscale" in the panel picker, and the server picker rendered "Headscale" as its
own heading. Those name OUR panels, not the upstream server, so they were simply
missed.

Deliberately NOT a find-and-replace. Most of the ~175 "headscale" strings in here
are correct — "no active Headscale server", "headscale timed out", "is this a
Headscale server?" all refer to the thing this plugin drives, and renaming them
would make the errors lie. What was fixed is the plugin's own identity, plus nine
stale `/headscale` route paths that are wrong rather than merely old (the route is
`/offscale`) and one reference to `queries/headscale.ts`, a file the extraction
deleted.

The console's pty session key moves from `headscale-console-<id>` to
`offscale-console-<id>`. That one has a real cost: a console open at the moment of
the change keeps running in the sidecar under the old key with nothing pointing at
it, until that sidecar restarts. Paid once, deliberately, rather than carrying the
pre-extraction name in persisted state forever.

── The manifest was missing a dependency it has always had ──

`sidecar/ssh.ts` spawns `ssh`, and so does the reachability probe. The manifest
said "Offscale needed nothing", so install checked for nothing and a host without
an ssh client failed at RUNTIME with a spawn error — precisely the failure
`osDependencies` exists to turn into a refusal at install time. PLUGIN.md listed
ssh as a host dependency all along; the manifest was the file that was wrong.

Missed because ssh is on effectively every Linux box, which is also why declaring
it costs nothing there and is the whole difference where it is absent.

── Documentation that said the opposite of the code ──

PLUGIN.md instructed the reader to carry `assist.ts` in "but do not wire it up",
as a marker. It has been wired for some time: `policy.ts` routes
`POST /_officer/policy/assist`, `PolicyView.tsx` renders `<PolicyAssistant />`, and
the manifest declares the route in `readOnlyWrites`. Corrected rather than deleted,
because that instruction is exactly the kind someone would follow — a person
tidying "unused" code would have found it wired and assumed the document knew
something they did not.

Present-tense identifiers corrected to the shipped names; the "before" columns of
the migration tables still say headscale, because that is what they record. A note
at the top says which is which.

tsgo clean, 21 plugin tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:23:28 +00:00
2026-08-16 04:09:19 +00:00

Offscale

Run your tailnet from Officer: machines, users, pre-auth keys, the access policy and device invites.

Offscale is an admin surface for Headscale, the open-source Tailscale control server. It is not a fork of Headscale and not a rename of it — the server underneath is stock, and Offscale is the tooling layer on top: it composes calls, normalises what comes back, and gates the whole thing behind Officer's permissions. The naming follows that split exactly. Everything Officer owns is offscale; the server being managed is a Headscale server and is still called one, so an error reading headscale unreachable means precisely what it says.

You register one or more Headscale servers with a URL and an admin API key. One is active at a time and every screen except Servers acts on it.

What it gives you

Eight sections, at /offscale/<section>:

  • Servers — register, edit, activate and deregister Headscale servers. Registration is validated before it is saved: an unauthenticated GET /version proves something Headscale-shaped is there and enforces the version floor, then an authenticated call proves the key works. A rejected registration is never written. Per-server health probes report reachable/version/key-still-accepted.
  • Nodes — list (with online state, addresses, tags and routes), rename, set tags, approve or withdraw advertised subnet routes, re-own a node to a different user, expire its key to force re-auth, delete.
  • Users — list with a per-user node count that the admin API does not provide, create, rename, delete. Headscale refuses to delete a user that still owns nodes; the count is there so you can see that coming.
  • Pre-auth keys — list with secrets masked and a derived active/used/expired status, create (reusable, ephemeral, expiry, ACL tags), expire, delete. The secret exists exactly once, in the creation response: Headscale stores keys hashed, so nothing can show it again afterwards.
  • Device invites — mint a single-use link with a TTL, send it to whoever is joining, list and revoke. The pre-auth key is minted when the link is claimed, not when it is created, so an unclaimed invite has no credential attached to it. Requires the Officer Companion — see Requirements.
  • Access policy — read and write the tailnet's HuJSON ACL document in an editor with a diff. The text goes up byte for byte; comments and layout are not reformatted. Validation is entirely Headscale's, and its rejection message is shown verbatim.
  • Diagnostics — container state, verdict, likely causes and a live log tail for the machine running Headscale, plus start/stop/restart. Requires the Officer Companion.
  • Console — a terminal on the machine behind the active server, opened by running ssh <host> in your own shell. The escape hatch for when the API cannot answer: Headscale down, tailnet down, logs the only evidence.

The Access policy section also has an assistant: describe a change in English and it returns a complete revised document as a draft in the editor. It never saves, never validates, and sends no credentials — only user, node and tag names, so the rules it writes reference things that exist. It needs Officer's officer-anthropic-proxy running; without it the button reports the assistant is unavailable.

Requirements

  • A Headscale server, 0.29 or newer, reachable from the Officer host over HTTP(S). Older releases are refused at registration. The admin API changed shape repeatedly below 0.29 — identifiers went name→numeric at 0.26, /api/v1/routes was removed at 0.26, forcedTags/validTags collapsed into tags at 0.28 — and supporting that range would mean carrying several incompatible data models. A self-built server reporting the literal version dev is accepted as "unknown" rather than refused.
  • An admin API key for each server, generated on the server itself with headscale apikeys create.
  • ssh on the Officer host, for the Console section only. Offscale holds no key, password or port — it runs ssh <host> in the owner's shell, so it reaches whatever ~/.ssh already reaches. A jump host or an odd port belongs in ~/.ssh/config as a Host alias, which the field accepts by name. This is not declared as an OS dependency, so nothing checks for ssh at install time.
  • The Officer Companion, optionally, per server, for Diagnostics and Device invites. It is a small service deployed next to Headscale at ${server.url}/officer-api, authenticating with the same admin API key Offscale already stores — so it keeps working while Headscale is down, which is when restarting it matters. It is a separate project (officerdev/offscale-server), not shipped here and not in the marketplace. A server without one is a normal state rather than an error: those two sections say what is missing and everything else works unchanged.
  • Officer's officer-anthropic-proxy, optionally, for the policy assistant.

Nothing needs to be configured before install, and nothing goes in .env.

Install

From Officer's Plugins page: find Offscale, press Install. That clones this repository into plugins/offscale, applies its one table, publishes the icon, pins the dock tile, registers officer-offscale with PM2 and starts it. The screen appears at /offscale.

Uninstall stops and removes the sidecar and unmounts the routes. It leaves the table and its rows alone — your registered servers survive a reinstall.

Permissions

One permission, offscale, covering the whole surface: "the tailnet — machines, routes, keys and ACLs". Like every plugin permission it is grantable per role at read or write, on the same page as the platform's own. The owner bypasses it.

Two requests that look like writes are declared as reads, because they are: the SSH reachability probe, and the policy assistant, which proposes a document and never saves one.

Be deliberate about granting write. The stored credential is a Headscale admin API key and there is no read-only version of one, so write on this permission is close to full control of the tailnet — including deleting every node.

Configuration

All of it is done in the app, in the Servers section. There are no environment variables, and the sidecar deliberately reads none: a registered server can therefore never be silently shadowed by host env.

Each registration holds a name, the server's base URL, an admin API key, and optionally an SSH address for the Console. Give the console the machine's own address rather than the Headscale hostname — the whole point of it is reaching the box when that name has stopped resolving.

Registrations live in offscale_servers in Officer's Postgres. The API key is encrypted at rest with the platform's key, decrypted only inside db/queries.ts, and never returned to the browser by any route. The SSH address is a plain host string with no credential in it.

How it works

browser  ──▶  /api/offscale/_officer/nodes        the platform: auth and the permission gate only
         ──▶  127.0.0.1:<ephemeral>/_officer/nodes   the officer-offscale sidecar
         ──▶  https://<registered>/api/v1/node       the Headscale admin API, with the stored key
  • api/router.ts is a thin auth-gated forwarder with no Headscale knowledge. The platform holds no Headscale credential and does not know any Headscale URL — it knows a permission key and a port.
  • sidecar/ owns the whole Headscale contract: the registry, the version floor, and every composition that takes more than one upstream call. It binds loopback on an ephemeral port and trusts the X-Officer-User header the platform injects. pm2 restart officer-offscale, pm2 logs officer-offscale.
  • There is deliberately no transparent /api/v1/* passthrough. Headscale's REST layer is a gRPC gateway, and its protobuf leaks — uint64 ids as JSON strings, unset timestamps as 0001-01-01T00:00:00Z, absent fields as []/null — are absorbed in sidecar/normalize.ts rather than pushed into the browser. Ids stay strings end to end; never parse one to a number.
  • db/schema.ts holds one table, offscale_servers, with a partial unique index enforcing at most one active server per owner.
  • web/ contributes three panels and a layout; the shell renders the workspace around them. Which section is open is the URL, not component state, so every section is linkable.

The published HTTP contract is OFFSCALE_API.md. PLUGIN.md is the design record — why the plugin boundary falls where it does, and what extracting this from the platform cost. It is a live working document about the plugin system generally, kept here because Offscale is the worked example; parts of it have been overtaken by the code.

Known limitations

  • Role grants are not finished. The queries still scope by the caller, so a member granted offscale would see their own empty server list rather than the owner's, and could register a Headscale of their own. The intended model is one shared resource: read sees what the owner sees, write can change it. Until that lands, treat this as an owner-only plugin.
  • Diagnostics and Device invites need the Officer Companion, a separate service that is not in this repository and not installable from the marketplace. Its API contract is not published — the two specification documents the code cites do not ship with either project. Without a companion, use pre-auth keys instead of invites and the Console instead of Diagnostics.
  • The Console imports the platform's terminal panel. That is a code dependency on something outside this plugin, and it is unresolved: either the terminal becomes host API, or the Console section stops travelling with the plugin.
  • Claiming an invite needs Officer's mobile app. The link opens a page on the server's own domain that redirects into an officer-offscale:// deep link.
  • Whether the policy is writable cannot be known in advance. A Headscale configured with policy.mode: file still serves the document over the API but refuses a PUT, and there is no endpoint that reports the mode. The first save is what finds out; a refusal becomes a persistent read-only banner.
  • The policy is never pre-validated by Offscale, on purpose. A second, weaker parser here would reject documents Headscale accepts and — worse — accept ones it rejects. A proposal that looks fine and is refused on save is a normal outcome.
  • Headscale below 0.29 is refused outright, with no compatibility path.
  • OFFSCALE_API.md is incomplete. It documents the server registry, nodes, users, pre-auth keys and enrolment, but not the policy routes, the assistant, the SSH probe, the companion routes, the invite routes, or re-owning a node. The code is the authority until it catches up.
  • POST /_officer/enroll has no direct caller. Its door — the platform's /api/vpn/enroll — was deleted on 2026-08-14. The handler is kept because invites dispatch through it and because a route under /api/offscale would reuse it.
  • Some UI copy still says "Headscale" where it means Offscale — panel titles, mostly, and a number of stale /headscale paths in comments. Being worked through.
S
Description
Headscale plugin for Officer
Readme
3.4 MiB
Languages
TypeScript 100%