From fa81f052060e340ed3fcb4746784dbfa5219133e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 04:09:19 +0000 Subject: [PATCH] readme --- README.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..37d01ca --- /dev/null +++ b/README.md @@ -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/
`: + +- **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 ` 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 ` 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:/_officer/nodes the officer-offscale sidecar + ──▶ https:///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.