This commit is contained in:
2026-08-16 04:09:19 +00:00
parent 95b84ea748
commit fa81f05206
+161
View File
@@ -0,0 +1,161 @@
# Offscale
Run your tailnet from Officer: machines, users, pre-auth keys, the access policy and device invites.
Offscale is an admin surface for [Headscale](https://github.com/juanfont/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](./OFFSCALE_API.md). [PLUGIN.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.