162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
# 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.
|