11 KiB
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 /versionproves 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/routeswas removed at 0.26,forcedTags/validTagscollapsed intotagsat 0.28 — and supporting that range would mean carrying several incompatible data models. A self-built server reporting the literal versiondevis accepted as "unknown" rather than refused. - An admin API key for each server, generated on the server itself with
headscale apikeys create. sshon the Officer host, for the Console section only. Offscale holds no key, password or port — it runsssh <host>in the owner's shell, so it reaches whatever~/.sshalready reaches. A jump host or an odd port belongs in~/.ssh/configas a Host alias, which the field accepts by name. This is not declared as an OS dependency, so nothing checks forsshat 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.tsis 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 theX-Officer-Userheader 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 as0001-01-01T00:00:00Z, absent fields as[]/null— are absorbed insidecar/normalize.tsrather than pushed into the browser. Ids stay strings end to end; never parse one to a number. db/schema.tsholds 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
offscalewould 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: filestill serves the document over the API but refuses aPUT, 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.mdis 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/enrollhas 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/offscalewould reuse it.- Some UI copy still says "Headscale" where it means Offscale — panel titles, mostly, and a number of
stale
/headscalepaths in comments. Being worked through.