rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# The OffScale HTTP contract
|
||||||
|
|
||||||
|
What `officer-offscale` serves, and what the platform forwards to it.
|
||||||
|
|
||||||
|
OffScale is Officer's tooling layer over Headscale. It adds nothing destructive and deviates from
|
||||||
|
nothing Headscale does — it composes, normalises and gates. 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 the sidecar registers as `offscale`, mounts at `/api/offscale`, and stores its rows in
|
||||||
|
`offscale_servers` — while an error saying `headscale unreachable` means precisely what it says.
|
||||||
|
|
||||||
|
## How a request gets here
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──▶ /api/offscale/_officer/nodes the platform: auth + permission gate only
|
||||||
|
──▶ createSidecarProxy strips the prefix ../api/router.ts, derived from mountPrefix()
|
||||||
|
──▶ 127.0.0.1:<ephemeral>/_officer/nodes this sidecar
|
||||||
|
──▶ https://<registered>/api/v1/node the Headscale admin API, with the stored key
|
||||||
|
```
|
||||||
|
|
||||||
|
The platform holds **no Headscale credential** and does not know any Headscale URL. It knows a
|
||||||
|
permission key and a port. That is the whole of its involvement.
|
||||||
|
|
||||||
|
The prefix is derived from `mountPrefix()`, never written as a literal — the proxy strips
|
||||||
|
`prefix.length` characters, so a hardcoded `/api/offscale` would forward the wrong subpath the moment
|
||||||
|
this plugin were published by anyone but `officerdev` (it would mount at `/api/p/<publisher>/offscale`).
|
||||||
|
|
||||||
|
## Many servers, one active
|
||||||
|
|
||||||
|
Officer manages **many** Headscale servers, not one. Each is registered with a URL and an API key
|
||||||
|
generated on that server; one is active at a time.
|
||||||
|
|
||||||
|
Configuration therefore lives in Postgres (`offscale_servers`, keys encrypted at rest) and **not** in
|
||||||
|
environment variables. This sidecar deliberately reads neither `OFFSCALE_URL` nor `OFFSCALE_API_KEY`, so
|
||||||
|
a registered server can never be silently shadowed by host env.
|
||||||
|
|
||||||
|
Every route below `/_officer/servers` acts on the **active** server and answers `409` when none is
|
||||||
|
selected.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Liveness
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| ------ | ---------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/_health` | This sidecar only. Per-server reachability is a different question — see `/servers/:id/health`. |
|
||||||
|
|
||||||
|
### Server registry
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| -------- | -------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/_officer/servers` | Registered servers. **Never** includes API keys. |
|
||||||
|
| `POST` | `/_officer/servers` | `{name?, url, apiKey}` — validated against the server before it is saved. |
|
||||||
|
| `PATCH` | `/_officer/servers/:id` | Re-validated when `url` or `apiKey` changes. |
|
||||||
|
| `DELETE` | `/_officer/servers/:id` | Promotes the newest survivor if the deleted one was active. |
|
||||||
|
| `POST` | `/_officer/servers/:id/activate` | Switch the active server. |
|
||||||
|
| `GET` | `/_officer/servers/:id/health` | Reachable? Version? Is the key still accepted? |
|
||||||
|
|
||||||
|
### Nodes
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| -------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/_officer/nodes` | Normalised. `?user=<username>` filters. |
|
||||||
|
| `GET` | `/_officer/nodes/:id` | |
|
||||||
|
| `DELETE` | `/_officer/nodes/:id` | Removes it from the tailnet. |
|
||||||
|
| `POST` | `/_officer/nodes/:id/rename` | `{name}` |
|
||||||
|
| `POST` | `/_officer/nodes/:id/tags` | `{tags}` — a `tag:` prefix is added when missing. |
|
||||||
|
| `POST` | `/_officer/nodes/:id/routes` | `{routes}` for the whole set, or `{route, approved}` for a single toggle (read-modify-write happens here). |
|
||||||
|
| `POST` | `/_officer/nodes/:id/expire` | Expires its key, forcing re-auth. **Not** a delete. |
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| -------- | ---------------------------- | ------------------------------------------------------ |
|
||||||
|
| `GET` | `/_officer/users` | Each with a node count the admin API does not provide. |
|
||||||
|
| `POST` | `/_officer/users` | `{name, displayName?, email?}` |
|
||||||
|
| `POST` | `/_officer/users/:id/rename` | `{name}` |
|
||||||
|
| `DELETE` | `/_officer/users/:id` | Refused upstream while the user still owns nodes. |
|
||||||
|
|
||||||
|
### Pre-auth keys
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| -------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/_officer/keys` | Secrets masked, with a derived status. |
|
||||||
|
| `POST` | `/_officer/keys` | `{userId, reusable?, ephemeral?, expirationDays?, aclTags?}` — **the only response carrying the real secret.** |
|
||||||
|
| `POST` | `/_officer/keys/:id/expire` | Expire without deleting. |
|
||||||
|
| `DELETE` | `/_officer/keys/:id` | Delete outright. |
|
||||||
|
|
||||||
|
### Enrollment
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `POST` | `/_officer/enroll` | `{userId?}` → `{controlUrl, authKey}`, a single-use 10-minute key for a joining device. `userId` is required only when the server has more than one user. |
|
||||||
|
|
||||||
|
`/_officer/enroll` has had **no direct caller since 2026-08-14**, when `/api/vpn/enroll` was deleted. It
|
||||||
|
is kept because it is the handler a route under `/api/offscale` would reuse, and because
|
||||||
|
`/enroll/invites` — which is live — dispatches through the same function.
|
||||||
|
|
||||||
|
Anything else: `404`.
|
||||||
|
|
||||||
|
## There is deliberately no `/api/v1/*` passthrough
|
||||||
|
|
||||||
|
Headscale's REST shape changed repeatedly below 0.29 and its ids are uint64-as-JSON-string. Proxying raw
|
||||||
|
would push all of that into the browser — the mistake the Soulseek panels made with 37 raw upstream
|
||||||
|
calls. Every quirk is absorbed here instead.
|
||||||
|
|
||||||
|
## The three protobuf leaks, and where they are contained
|
||||||
|
|
||||||
|
Headscale's REST layer is a gRPC gateway marshalling protobuf. It leaks in exactly three ways, all
|
||||||
|
handled in `sidecar/normalize.ts` (and pinned by `sidecar/normalize.test.ts`):
|
||||||
|
|
||||||
|
1. **Every uint64 is a JSON string.** Ids stay strings end to end. Never `Number()` them — it breaks
|
||||||
|
silently above 2^53, and Headscale's ids are database-assigned, not small by contract.
|
||||||
|
2. **Unset timestamps are the protobuf zero value**, serialised as `0001-01-01T00:00:00Z` rather than
|
||||||
|
omitted. Rendered naively that reads as the year 1; it means "never", so it becomes `null`.
|
||||||
|
3. **`EmitUnpopulated`** means absent repeated fields arrive as `[]` and absent messages as `null`. There
|
||||||
|
is no way to distinguish "unset" from "empty", so every accessor tolerates both.
|
||||||
|
|
||||||
|
## The version floor
|
||||||
|
|
||||||
|
Officer targets **Headscale >= 0.29** and nothing older, checked once at registration by an
|
||||||
|
unauthenticated `GET /version` on the server itself. Below that floor the admin API changed shape
|
||||||
|
repeatedly — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at 0.26 in favour of
|
||||||
|
node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth key expiry became
|
||||||
|
id-based at 0.28, and `MoveNode` was removed at 0.28. Supporting 0.23–0.28 would mean carrying several
|
||||||
|
incompatible data models; refusing them at registration costs one probe.
|
||||||
|
|
||||||
|
A version that will not parse — a self-built image reporting the literal `dev` — is reported as
|
||||||
|
`supported: 'unknown'` rather than refused. Locking out legitimately self-built deployments would be the
|
||||||
|
worse failure. `sidecar/version.test.ts` pins that case specifically.
|
||||||
|
|
||||||
|
Do not confuse `GET /version` with the two similarly-named endpoints: `GET /health` (root,
|
||||||
|
unauthenticated, `{status:'pass'}`) and `GET /api/v1/health` (authenticated,
|
||||||
|
`{databaseConnectivity:true}`) carry no version at all.
|
||||||
+24
-7
@@ -1,18 +1,35 @@
|
|||||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
// /api/offscale/* — auth, then forward to officer-offscale. No routes of its own and no Headscale
|
||||||
// this file must never grow app logic.
|
// knowledge: this file must never grow app logic.
|
||||||
//
|
//
|
||||||
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
|
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
|
||||||
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
|
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
|
||||||
// does not know Headscale's URL.
|
// does not know any Headscale URL.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/offscale'. That is wrong in a way that only shows up for someone else's
|
||||||
|
// plugin: the proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||||
|
// '/api/offscale' (13 chars) is correct only because `mountPrefix` happens to return `/offscale` for a
|
||||||
|
// first-party publisher. The same plugin published by anyone else mounts at
|
||||||
|
// `/api/p/<publisher>/offscale` and would forward `/alice/offscale/nodes` to a sidecar expecting
|
||||||
|
// `/nodes`.
|
||||||
|
//
|
||||||
|
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||||
|
// literal here is that rule being broken quietly, which is how first-party and third-party become two
|
||||||
|
// systems with only one of them tested. Carried over from music, which hit this first.
|
||||||
|
//
|
||||||
|
// `appName` is a literal because this file cannot see its own directory name — the platform imports
|
||||||
|
// `router.ts` and reads `router`, so there is nowhere to inject it. Same known gap music records.
|
||||||
const proxy = createSidecarProxy({
|
const proxy = createSidecarProxy({
|
||||||
name: 'headscale',
|
name: 'offscale',
|
||||||
prefix: '/api/offscale',
|
prefix: `/api${mountPrefix({ appName: 'offscale', manifest })}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const router = proxy.router;
|
export const router = proxy.router;
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
export const getOffscaleServerUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
+58
-58
@@ -1,20 +1,20 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from 'officerdb/db';
|
import { db } from 'officerdb/db';
|
||||||
import { headscaleServers } from './schema';
|
import { offscaleServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
|
||||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
// Headscale server registry access for the officer-offscale sidecar. Callers deal in PLAINTEXT —
|
||||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||||
// See ../crypto.ts and ../schema/headscale.ts.
|
// See ./schema.ts — the encryption lives in the platform, via officerdb.
|
||||||
//
|
//
|
||||||
// Two return types on purpose:
|
// Two return types on purpose:
|
||||||
// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all.
|
// OffscaleServer — safe to serialize to the browser. Has NO api key field at all.
|
||||||
// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
|
// OffscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
|
||||||
// by a route handler.
|
// by a route handler.
|
||||||
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
|
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
|
||||||
// into every list response the moment someone forgot to strip it.
|
// into every list response the moment someone forgot to strip it.
|
||||||
|
|
||||||
export type HeadscaleServer = {
|
export type OffscaleServer = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -25,49 +25,49 @@ export type HeadscaleServer = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
|
export type OffscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
|
||||||
|
|
||||||
const serverCols = {
|
const serverCols = {
|
||||||
id: headscaleServers.id,
|
id: offscaleServers.id,
|
||||||
name: headscaleServers.name,
|
name: offscaleServers.name,
|
||||||
url: headscaleServers.url,
|
url: offscaleServers.url,
|
||||||
version: headscaleServers.version,
|
version: offscaleServers.version,
|
||||||
sshHost: headscaleServers.sshHost,
|
sshHost: offscaleServers.sshHost,
|
||||||
isActive: headscaleServers.isActive,
|
isActive: offscaleServers.isActive,
|
||||||
lastSeenAt: headscaleServers.lastSeenAt,
|
lastSeenAt: offscaleServers.lastSeenAt,
|
||||||
createdAt: headscaleServers.createdAt,
|
createdAt: offscaleServers.createdAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Every server the owner has registered, active first then newest. Never includes the API key. */
|
/** Every server the owner has registered, active first then newest. Never includes the API key. */
|
||||||
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
|
export async function listOffscaleServers(userId: number): Promise<OffscaleServer[]> {
|
||||||
return db
|
return db
|
||||||
.select(serverCols)
|
.select(serverCols)
|
||||||
.from(headscaleServers)
|
.from(offscaleServers)
|
||||||
.where(eq(headscaleServers.userId, userId))
|
.where(eq(offscaleServers.userId, userId))
|
||||||
.orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt));
|
.orderBy(desc(offscaleServers.isActive), desc(offscaleServers.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The currently selected server with its key decrypted, or null when none is registered/active. */
|
/** The currently selected server with its key decrypted, or null when none is registered/active. */
|
||||||
export async function getActiveHeadscaleCredentials(userId: number): Promise<HeadscaleServerCredentials | null> {
|
export async function getActiveOffscaleCredentials(userId: number): Promise<OffscaleServerCredentials | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(headscaleServers)
|
.from(offscaleServers)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', row.apiKey) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||||
export async function getHeadscaleCredentials(userId: number, id: number): Promise<HeadscaleServerCredentials | null> {
|
export async function getOffscaleCredentials(userId: number, id: number): Promise<OffscaleServerCredentials | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(headscaleServers)
|
.from(offscaleServers)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', row.apiKey) };
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateHeadscaleServerParams = {
|
type CreateOffscaleServerParams = {
|
||||||
userId: number;
|
userId: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -80,22 +80,22 @@ type CreateHeadscaleServerParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Register a server. The key is encrypted before write; the returned row carries no key. */
|
/** Register a server. The key is encrypted before write; the returned row carries no key. */
|
||||||
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
|
export async function createOffscaleServer(params: CreateOffscaleServerParams): Promise<OffscaleServer> {
|
||||||
const { userId, name, url, apiKey, version, sshHost, activate } = params;
|
const { userId, name, url, apiKey, version, sshHost, activate } = params;
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
if (activate) {
|
if (activate) {
|
||||||
await tx
|
await tx
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set({ isActive: false, updatedAt: new Date() })
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||||
}
|
}
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.insert(headscaleServers)
|
.insert(offscaleServers)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
apiKey: encryptSecret('headscale', apiKey),
|
apiKey: encryptSecret('offscale', apiKey),
|
||||||
version,
|
version,
|
||||||
sshHost,
|
sshHost,
|
||||||
isActive: activate,
|
isActive: activate,
|
||||||
@@ -108,39 +108,39 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
|||||||
|
|
||||||
// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay
|
// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay
|
||||||
// distinguishable, which is why this is `string | null` and not `string`.
|
// distinguishable, which is why this is `string | null` and not `string`.
|
||||||
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null };
|
type UpdateOffscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null };
|
||||||
|
|
||||||
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
|
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
|
||||||
export async function updateHeadscaleServer(
|
export async function updateOffscaleServer(
|
||||||
userId: number,
|
userId: number,
|
||||||
id: number,
|
id: number,
|
||||||
params: UpdateHeadscaleServerParams,
|
params: UpdateOffscaleServerParams,
|
||||||
): Promise<HeadscaleServer | null> {
|
): Promise<OffscaleServer | null> {
|
||||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (params.name !== undefined) set.name = params.name;
|
if (params.name !== undefined) set.name = params.name;
|
||||||
if (params.url !== undefined) set.url = params.url;
|
if (params.url !== undefined) set.url = params.url;
|
||||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
|
if (params.apiKey !== undefined) set.apiKey = encryptSecret('offscale', params.apiKey);
|
||||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set(set)
|
.set(set)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)))
|
||||||
.returning(serverCols);
|
.returning(serverCols);
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */
|
/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */
|
||||||
export async function setActiveHeadscaleServer(userId: number, id: number): Promise<HeadscaleServer | null> {
|
export async function setActiveOffscaleServer(userId: number, id: number): Promise<OffscaleServer | null> {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set({ isActive: false, updatedAt: new Date() })
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set({ isActive: true, updatedAt: new Date() })
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)))
|
||||||
.returning(serverCols);
|
.returning(serverCols);
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
});
|
});
|
||||||
@@ -151,26 +151,26 @@ export async function setActiveHeadscaleServer(userId: number, id: number): Prom
|
|||||||
* active server would leave the UI with servers registered but none selected, which reads as "not
|
* active server would leave the UI with servers registered but none selected, which reads as "not
|
||||||
* configured" and is a confusing place to land.
|
* configured" and is a confusing place to land.
|
||||||
*/
|
*/
|
||||||
export async function deleteHeadscaleServer(userId: number, id: number): Promise<boolean> {
|
export async function deleteOffscaleServer(userId: number, id: number): Promise<boolean> {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [deleted] = await tx
|
const [deleted] = await tx
|
||||||
.delete(headscaleServers)
|
.delete(offscaleServers)
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)))
|
||||||
.returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive });
|
.returning({ id: offscaleServers.id, wasActive: offscaleServers.isActive });
|
||||||
if (!deleted) return false;
|
if (!deleted) return false;
|
||||||
|
|
||||||
if (deleted.wasActive) {
|
if (deleted.wasActive) {
|
||||||
const [next] = await tx
|
const [next] = await tx
|
||||||
.select({ id: headscaleServers.id })
|
.select({ id: offscaleServers.id })
|
||||||
.from(headscaleServers)
|
.from(offscaleServers)
|
||||||
.where(eq(headscaleServers.userId, userId))
|
.where(eq(offscaleServers.userId, userId))
|
||||||
.orderBy(desc(headscaleServers.createdAt))
|
.orderBy(desc(offscaleServers.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (next) {
|
if (next) {
|
||||||
await tx
|
await tx
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set({ isActive: true, updatedAt: new Date() })
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
.where(eq(headscaleServers.id, next.id));
|
.where(eq(offscaleServers.id, next.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -178,9 +178,9 @@ export async function deleteHeadscaleServer(userId: number, id: number): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Record a successful reachability probe: the version observed and when we last reached the server. */
|
/** Record a successful reachability probe: the version observed and when we last reached the server. */
|
||||||
export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
|
export async function recordOffscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(headscaleServers)
|
.update(offscaleServers)
|
||||||
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
||||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)));
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -2,7 +2,7 @@ import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from 'officerdb/auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
// The Headscale servers the owner manages, for the officer-offscale sidecar. Officer targets no single
|
||||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
|
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
|
||||||
//
|
//
|
||||||
@@ -12,12 +12,12 @@ import { users } from 'officerdb/auth/schema';
|
|||||||
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
|
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
|
||||||
// email/integrations tables are debt to avoid copying, not a precedent to follow.
|
// email/integrations tables are debt to avoid copying, not a precedent to follow.
|
||||||
//
|
//
|
||||||
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
|
// Every table here is `offscale_`-prefixed and this file holds nothing else: when sidecars own their own
|
||||||
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
|
// schema it moves wholesale into ../sidecar/ with no untangling. Only the
|
||||||
// officer-headscale sidecar reads or writes these tables.
|
// officer-offscale sidecar reads or writes these tables.
|
||||||
|
|
||||||
export const headscaleServers = pgTable(
|
export const offscaleServers = pgTable(
|
||||||
'headscale_servers',
|
'offscale_servers',
|
||||||
{
|
{
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
userId: integer('user_id')
|
userId: integer('user_id')
|
||||||
@@ -44,11 +44,11 @@ export const headscaleServers = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
|
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
|
||||||
uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url),
|
uniqueIndex('uq_offscale_servers_user_url').on(t.userId, t.url),
|
||||||
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
||||||
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
|
// index over the active rows only. setActiveOffscaleServer still clears the others in a transaction,
|
||||||
// but a bug there fails loudly here instead of silently leaving two servers active.
|
// but a bug there fails loudly here instead of silently leaving two servers active.
|
||||||
uniqueIndex('uq_headscale_servers_one_active')
|
uniqueIndex('uq_offscale_servers_one_active')
|
||||||
.on(t.userId)
|
.on(t.userId)
|
||||||
.where(sql`${t.isActive}`),
|
.where(sql`${t.isActive}`),
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ export const manifest: PluginManifest = {
|
|||||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
//
|
//
|
||||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||||
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
// still scope by the caller (`listOffscaleServers(userId)`), so a granted member would see their own
|
||||||
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||||
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||||
// That is a change inside these queries, not a flag on the manifest.
|
// That is a change inside these queries, not a flag on the manifest.
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
import { getActiveOffscaleCredentials } from '../db/queries';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type OffscaleClient } from './client';
|
||||||
|
|
||||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||||
// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so
|
// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so
|
||||||
@@ -11,8 +11,8 @@ import { createClient, type HeadscaleClient } from './client';
|
|||||||
* 409 rather than 404: the route exists and the request was well-formed, the account just has no server
|
* 409 rather than 404: the route exists and the request was well-formed, the account just has no server
|
||||||
* selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone".
|
* selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone".
|
||||||
*/
|
*/
|
||||||
export async function activeClient(userId: number): Promise<HeadscaleClient | Response> {
|
export async function activeClient(userId: number): Promise<OffscaleClient | Response> {
|
||||||
const creds = await getActiveHeadscaleCredentials(userId);
|
const creds = await getActiveOffscaleCredentials(userId);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
import type { OffscaleServerCredentials } from '../db/queries';
|
||||||
|
|
||||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
// wire-level quirks are handled once:
|
// wire-level quirks are handled once:
|
||||||
@@ -18,7 +18,7 @@ import type { HeadscaleServerCredentials } from '../db/queries';
|
|||||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
||||||
export class HeadscaleError extends Error {
|
export class OffscaleError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -33,7 +33,7 @@ export class HeadscaleError extends Error {
|
|||||||
readonly detail?: string,
|
readonly detail?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'HeadscaleError';
|
this.name = 'OffscaleError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ async function errorMessage(res: Response): Promise<string> {
|
|||||||
return text.slice(0, 300);
|
return text.slice(0, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeadscaleClient = {
|
export type OffscaleClient = {
|
||||||
readonly serverId: number;
|
readonly serverId: number;
|
||||||
/** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
|
/** Call an admin API path (e.g. `/api/v1/node`). Throws OffscaleError on any non-2xx. */
|
||||||
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
|
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build a client bound to one registered server's credentials. */
|
/** Build a client bound to one registered server's credentials. */
|
||||||
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
|
export function createClient(creds: OffscaleServerCredentials): OffscaleClient {
|
||||||
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
|
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
||||||
|
|
||||||
@@ -82,13 +82,13 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
||||||
throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
|
throw new OffscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
|
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
|
||||||
// Officer-side auth problem — the UI should point the owner at re-entering the key.
|
// Officer-side auth problem — the UI should point the owner at re-entering the key.
|
||||||
throw new HeadscaleError(502, 'headscale rejected the stored API key');
|
throw new OffscaleError(502, 'headscale rejected the stored API key');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -96,7 +96,7 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient
|
|||||||
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
|
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
|
||||||
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
|
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
|
||||||
const serverSide = res.status >= 500;
|
const serverSide = res.status >= 500;
|
||||||
throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
|
throw new OffscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
|
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
|
||||||
@@ -105,7 +105,7 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} catch {
|
||||||
throw new HeadscaleError(502, 'headscale returned a non-JSON body');
|
throw new OffscaleError(502, 'headscale returned a non-JSON body');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
import { getActiveOffscaleCredentials, type OffscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||||
|
|
||||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||||
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
|
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
|
||||||
// Contract: COMMS/HEADSCALE_COMPANION_API.md.
|
// Contract: COMMS/OFFSCALE_COMPANION_API.md.
|
||||||
//
|
//
|
||||||
// Three facts shape everything here.
|
// Three facts shape everything here.
|
||||||
//
|
//
|
||||||
@@ -38,7 +38,7 @@ type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?
|
|||||||
* "unreachable" is information rather than a failure.
|
* "unreachable" is information rather than a failure.
|
||||||
*/
|
*/
|
||||||
export async function callCompanion(
|
export async function callCompanion(
|
||||||
creds: HeadscaleServerCredentials,
|
creds: OffscaleServerCredentials,
|
||||||
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
|
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
|
||||||
): Promise<Response | string> {
|
): Promise<Response | string> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -85,8 +85,8 @@ export async function readBody(res: Response): Promise<Record<string, unknown> |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The active server's credentials, or a 409 the UI already knows how to render. */
|
/** The active server's credentials, or a 409 the UI already knows how to render. */
|
||||||
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
export async function activeCreds(userId: number): Promise<OffscaleServerCredentials | Response> {
|
||||||
const creds = await getActiveHeadscaleCredentials(userId);
|
const creds = await getActiveOffscaleCredentials(userId);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ export async function activeCreds(userId: number): Promise<HeadscaleServerCreden
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
|
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
|
||||||
async function health(creds: HeadscaleServerCredentials): Promise<Response> {
|
async function health(creds: OffscaleServerCredentials): Promise<Response> {
|
||||||
const res = await callCompanion(creds, { path: '/health' });
|
const res = await callCompanion(creds, { path: '/health' });
|
||||||
if (typeof res === 'string') return Response.json(unavailable(res));
|
if (typeof res === 'string') return Response.json(unavailable(res));
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ async function health(creds: HeadscaleServerCredentials): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
|
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
|
||||||
async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Response> {
|
async function logs(creds: OffscaleServerCredentials, url: URL): Promise<Response> {
|
||||||
const tail = Number(url.searchParams.get('tail') ?? 200);
|
const tail = Number(url.searchParams.get('tail') ?? 200);
|
||||||
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000');
|
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000');
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Respon
|
|||||||
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
|
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
|
||||||
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
|
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
|
||||||
*/
|
*/
|
||||||
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
async function logStream(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||||
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
|
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
|
||||||
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000');
|
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000');
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ const ACTIONS = new Set(['restart', 'stop', 'start']);
|
|||||||
* Every one of these drops every node's control-plane connection for the duration. That is the intended
|
* Every one of these drops every node's control-plane connection for the duration. That is the intended
|
||||||
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
|
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
|
||||||
*/
|
*/
|
||||||
async function action(creds: HeadscaleServerCredentials, name: string): Promise<Response> {
|
async function action(creds: OffscaleServerCredentials, name: string): Promise<Response> {
|
||||||
// 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the
|
// 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the
|
||||||
// one question this feature exists to answer.
|
// one question this feature exists to answer.
|
||||||
const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 });
|
const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 });
|
||||||
|
|||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import type { OfficerUser } from './normalize';
|
import type { OfficerUser } from './normalize';
|
||||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
import { getActiveOffscaleCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type OffscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
import { handleInvitesRoute } from './invites';
|
import { handleInvitesRoute } from './invites';
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ import { handleInvitesRoute } from './invites';
|
|||||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||||
//
|
//
|
||||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||||
// HEADSCALE_URL, HEADSCALE_API_KEY
|
// OFFSCALE_URL, OFFSCALE_API_KEY
|
||||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
// and OFFSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||||
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
|
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
|
||||||
@@ -32,7 +32,7 @@ const KEY_TTL_MS = 10 * 60_000;
|
|||||||
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
|
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
|
||||||
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
|
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
|
||||||
*/
|
*/
|
||||||
async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
|
async function resolveOwner(client: OffscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
|
||||||
const body = await readJson(ctx.req);
|
const body = await readJson(ctx.req);
|
||||||
const requested = typeof body?.userId === 'string' ? body.userId.trim() : '';
|
const requested = typeof body?.userId === 'string' ? body.userId.trim() : '';
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export async function handleEnrollRoute(ctx: OfficerContext, segments: string[])
|
|||||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||||
|
|
||||||
// Not activeClient(): the control URL goes back to the device, and only the credentials carry it.
|
// Not activeClient(): the control URL goes back to the device, and only the credentials carry it.
|
||||||
const creds = await getActiveHeadscaleCredentials(ctx.userId);
|
const creds = await getActiveOffscaleCredentials(ctx.userId);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-15
@@ -4,19 +4,20 @@ import { handleOfficerRoute } from './routes';
|
|||||||
import { MIN_VERSION_LABEL } from './version';
|
import { MIN_VERSION_LABEL } from './version';
|
||||||
import { API_URL } from '@@/officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
// The officer-offscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||||
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
|
// API is a thin auth-gated forwarder (../api/router.ts) holding no Headscale credentials.
|
||||||
//
|
//
|
||||||
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
|
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
|
||||||
// generated on that server, and switches between them; one is active at a time. So configuration lives in
|
// generated on that server, and switches between them; one is active at a time. So configuration lives in
|
||||||
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
|
// Postgres (offscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
|
||||||
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
|
// neither OFFSCALE_URL nor OFFSCALE_API_KEY, so a registered server can never be shadowed by host env.
|
||||||
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
|
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
|
||||||
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
|
// OFFSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
|
// HTTP CONTRACT — the platform strips its /api/offscale mount prefix before forwarding.
|
||||||
|
// Published in full as ../OFFSCALE_API.md; keep the two in step.
|
||||||
//
|
//
|
||||||
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
|
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
|
||||||
// different question and needs an owner, so it lives below.
|
// different question and needs an owner, so it lives below.
|
||||||
@@ -79,7 +80,7 @@ const server = Bun.serve({
|
|||||||
// Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
|
// Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
|
||||||
// choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
|
// choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
|
||||||
if (url.pathname === '/_health') {
|
if (url.pathname === '/_health') {
|
||||||
return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL });
|
return Response.json({ ok: true, minOffscaleVersion: MIN_VERSION_LABEL });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/_officer/')) {
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
@@ -87,7 +88,7 @@ const server = Bun.serve({
|
|||||||
const res = await handleOfficerRoute(req, url);
|
const res = await handleOfficerRoute(req, url);
|
||||||
return res ?? new Response('not found', { status: 404 });
|
return res ?? new Response('not found', { status: 404 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[headscale] ${req.method} ${url.pathname} failed`, err);
|
console.error(`[offscale] ${req.method} ${url.pathname} failed`, err);
|
||||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);
|
console.log(`[offscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);
|
||||||
|
|
||||||
// ── Command handlers ──
|
// ── Command handlers ──
|
||||||
|
|
||||||
@@ -120,22 +121,22 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
|
|
||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'headscale',
|
name: 'offscale',
|
||||||
handles: ['headscale'],
|
handles: ['offscale'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
onConnected() {
|
onConnected() {
|
||||||
// Tell the API where we're listening, so it can forward /api/headscale/* here.
|
// Tell the API where we're listening, so it can forward /api/offscale/* here.
|
||||||
connection.send({ type: 'headscale:server', port });
|
connection.send({ type: 'offscale:server', port });
|
||||||
console.log(`[headscale] reported port ${port} to API`);
|
console.log(`[offscale] reported port ${port} to API`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
// ── Graceful shutdown ──
|
||||||
|
|
||||||
function shutdown(signal: string) {
|
function shutdown(signal: string) {
|
||||||
console.log(`[headscale] ${signal} received, shutting down...`);
|
console.log(`[offscale] ${signal} received, shutting down...`);
|
||||||
try {
|
try {
|
||||||
server.stop(true);
|
server.stop(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
import type { OffscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ function withNameHint(url: unknown, name: string | undefined): unknown {
|
|||||||
* dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of
|
* dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of
|
||||||
* a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it.
|
* a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it.
|
||||||
*/
|
*/
|
||||||
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
async function create(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||||
const input = parseCreate(await readJson(ctx.req));
|
const input = parseCreate(await readJson(ctx.req));
|
||||||
if (input instanceof Response) return input;
|
if (input instanceof Response) return input;
|
||||||
|
|
||||||
@@ -153,13 +153,13 @@ function pickInvites(body: Record<string, unknown>): unknown[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
|
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
|
||||||
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
|
async function list(creds: OffscaleServerCredentials): Promise<Response> {
|
||||||
const res = await callCompanion(creds, { path: INVITES_PATH });
|
const res = await callCompanion(creds, { path: INVITES_PATH });
|
||||||
return relay(res, (body) => ({ available: true, invites: pickInvites(body) }));
|
return relay(res, (body) => ({ available: true, invites: pickInvites(body) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
|
/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
|
||||||
async function revoke(creds: HeadscaleServerCredentials, id: string): Promise<Response> {
|
async function revoke(creds: OffscaleServerCredentials, id: string): Promise<Response> {
|
||||||
const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
|
const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
|
||||||
return relay(res, (body) => ({ available: true, ...body }));
|
return relay(res, (body) => ({ available: true, ...body }));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
|
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
|
||||||
import type { HeadscaleClient } from './client';
|
import type { OffscaleClient } from './client';
|
||||||
import { activeClient } from './active';
|
import { activeClient } from './active';
|
||||||
import { toNode, arrayField, type OfficerNode } from './normalize';
|
import { toNode, arrayField, type OfficerNode } from './normalize';
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ async function listNodes(ctx: OfficerContext): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */
|
/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */
|
||||||
async function getNode(client: HeadscaleClient, id: string): Promise<OfficerNode | null> {
|
async function getNode(client: OffscaleClient, id: string): Promise<OfficerNode | null> {
|
||||||
const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
|
const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
|
||||||
return body.node ? toNode(body.node) : null;
|
return body.node ? toNode(body.node) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { arrayField, isoOrNull, toNode, toUser } from './normalize';
|
||||||
|
|
||||||
|
// Headscale's REST layer is a gRPC gateway marshalling protobuf, and it leaks in three specific ways.
|
||||||
|
// These transforms are where that leak is contained, so they are the file most likely to be quietly wrong
|
||||||
|
// after an upstream release — and they had no tests at all.
|
||||||
|
|
||||||
|
describe('isoOrNull', () => {
|
||||||
|
test("the protobuf zero timestamp means 'never', not the year 1", () => {
|
||||||
|
// The leak that matters most: unset timestamps arrive as this literal rather than being omitted.
|
||||||
|
// Rendered naively a node's expiry reads as year 1, which looks like an expired node rather than one
|
||||||
|
// that never expires.
|
||||||
|
expect(isoOrNull('0001-01-01T00:00:00Z')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pre-1971 is treated as the sentinel too, for builds that emit a different zero', () => {
|
||||||
|
expect(isoOrNull('1970-01-01T00:00:00Z')).toBeNull();
|
||||||
|
expect(isoOrNull('1960-06-01T00:00:00Z')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real timestamp survives, normalised to ISO', () => {
|
||||||
|
expect(isoOrNull('2026-08-15T10:30:00Z')).toBe('2026-08-15T10:30:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anything that is not a usable string is null rather than a crash', () => {
|
||||||
|
// EmitUnpopulated means absent messages arrive as null, so these are normal input, not corruption.
|
||||||
|
for (const bad of [null, undefined, '', 'not a date', 42, {}, []]) {
|
||||||
|
expect(isoOrNull(bad)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toNode', () => {
|
||||||
|
test('ids stay STRINGS — never numbers', () => {
|
||||||
|
// Headscale ids are uint64 serialized as JSON strings. Number() breaks silently above 2^53, and the
|
||||||
|
// ids are database-assigned rather than small by contract, so this is a real ceiling and not theory.
|
||||||
|
const node = toNode({ id: '9007199254740993', name: 'a' });
|
||||||
|
expect(node.id).toBe('9007199254740993');
|
||||||
|
expect(typeof node.id).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('givenName wins over name, falling back when it is unset', () => {
|
||||||
|
expect(toNode({ id: '1', givenName: 'laptop', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop');
|
||||||
|
expect(toNode({ id: '1', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop.tail1234.ts.net');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an exit node is recognised from either default route', () => {
|
||||||
|
expect(toNode({ id: '1', availableRoutes: ['0.0.0.0/0'] }).isExitNode).toBe(true);
|
||||||
|
expect(toNode({ id: '1', availableRoutes: ['::/0'] }).isExitNode).toBe(true);
|
||||||
|
expect(toNode({ id: '1', availableRoutes: ['10.0.0.0/24'] }).isExitNode).toBe(false);
|
||||||
|
expect(toNode({ id: '1' }).isExitNode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown register method degrades rather than leaking the enum', () => {
|
||||||
|
expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_AUTH_KEY' }).registerMethod).toBe('authkey');
|
||||||
|
// A method added in a future release must not put REGISTER_METHOD_SOMETHING_NEW in the UI.
|
||||||
|
expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_FUTURE' }).registerMethod).toBe('unknown');
|
||||||
|
expect(toNode({ id: '1' }).registerMethod).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online is strictly true, so a missing field is offline rather than truthy', () => {
|
||||||
|
expect(toNode({ id: '1', online: true }).online).toBe(true);
|
||||||
|
expect(toNode({ id: '1', online: 'true' }).online).toBe(false);
|
||||||
|
expect(toNode({ id: '1' }).online).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a node with nothing but an id normalises instead of throwing', () => {
|
||||||
|
// EmitUnpopulated guarantees absent repeated fields arrive as [] and absent messages as null, and
|
||||||
|
// there is no way to tell "unset" from "empty" — so every accessor has to tolerate both.
|
||||||
|
const node = toNode({ id: '7' });
|
||||||
|
expect(node.ipAddresses).toEqual([]);
|
||||||
|
expect(node.tags).toEqual([]);
|
||||||
|
expect(node.user).toBeNull();
|
||||||
|
expect(node.lastSeen).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-string entries are dropped from string arrays rather than rendered', () => {
|
||||||
|
expect(toNode({ id: '1', ipAddresses: ['100.64.0.1', null, 42, '::1'] }).ipAddresses).toEqual([
|
||||||
|
'100.64.0.1',
|
||||||
|
'::1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toUser', () => {
|
||||||
|
test('null and undefined pass through as null', () => {
|
||||||
|
expect(toUser(null)).toBeNull();
|
||||||
|
expect(toUser(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arrayField', () => {
|
||||||
|
test('pulls the named array, keeping only objects', () => {
|
||||||
|
expect(arrayField({ nodes: [{ id: '1' }, null, 'x', { id: '2' }] }, 'nodes')).toEqual([{ id: '1' }, { id: '2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing field or a non-array body is an empty list, not a throw', () => {
|
||||||
|
expect(arrayField({}, 'nodes')).toEqual([]);
|
||||||
|
expect(arrayField(null, 'nodes')).toEqual([]);
|
||||||
|
expect(arrayField({ nodes: 'not-an-array' }, 'nodes')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { HeadscaleError } from './client';
|
import { OffscaleError } from './client';
|
||||||
import { activeClient } from './active';
|
import { activeClient } from './active';
|
||||||
import { handlePolicyAssistRoute } from './assist';
|
import { handlePolicyAssistRoute } from './assist';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ import { handlePolicyAssistRoute } from './assist';
|
|||||||
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
|
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
|
||||||
//
|
//
|
||||||
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
|
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
|
||||||
// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.
|
// to "headscale error". `OffscaleError.detail` is how the real message survives that; see client.ts.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
|
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
|
||||||
@@ -59,7 +59,7 @@ async function getPolicy(ctx: OfficerContext): Promise<Response> {
|
|||||||
const body = await client.call<PolicyBody>('/api/v1/policy');
|
const body = await client.call<PolicyBody>('/api/v1/policy');
|
||||||
return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
|
return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof HeadscaleError)) throw err;
|
if (!(err instanceof OffscaleError)) throw err;
|
||||||
const detail = err.detail ?? err.message;
|
const detail = err.detail ?? err.message;
|
||||||
if (err.status === 404 || detail.toLowerCase().includes('not found')) {
|
if (err.status === 404 || detail.toLowerCase().includes('not found')) {
|
||||||
return Response.json({ policy: '', updatedAt: null });
|
return Response.json({ policy: '', updatedAt: null });
|
||||||
@@ -91,7 +91,7 @@ async function putPolicy(ctx: OfficerContext): Promise<Response> {
|
|||||||
// never blanks the editor.
|
// never blanks the editor.
|
||||||
return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
|
return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof HeadscaleError)) throw err;
|
if (!(err instanceof OffscaleError)) throw err;
|
||||||
const detail = err.detail ?? err.message;
|
const detail = err.detail ?? err.message;
|
||||||
|
|
||||||
if (isWriteDisabled(detail)) {
|
if (isWriteDisabled(detail)) {
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { HeadscaleError } from './client';
|
import { OffscaleError } from './client';
|
||||||
import { handleServersRoute } from './servers';
|
import { handleServersRoute } from './servers';
|
||||||
import { handleNodesRoute } from './nodes';
|
import { handleNodesRoute } from './nodes';
|
||||||
import { handleUsersRoute } from './users';
|
import { handleUsersRoute } from './users';
|
||||||
@@ -74,7 +74,7 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise<Respon
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
|
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
|
||||||
if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status });
|
if (err instanceof OffscaleError) return Response.json({ error: err.message }, { status: err.status });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-20
@@ -1,14 +1,14 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import {
|
import {
|
||||||
listHeadscaleServers,
|
listOffscaleServers,
|
||||||
createHeadscaleServer,
|
createOffscaleServer,
|
||||||
updateHeadscaleServer,
|
updateOffscaleServer,
|
||||||
setActiveHeadscaleServer,
|
setActiveOffscaleServer,
|
||||||
deleteHeadscaleServer,
|
deleteOffscaleServer,
|
||||||
getHeadscaleCredentials,
|
getOffscaleCredentials,
|
||||||
recordHeadscaleProbe,
|
recordOffscaleProbe,
|
||||||
} from '../db/queries';
|
} from '../db/queries';
|
||||||
import { createClient, HeadscaleError } from './client';
|
import { createClient, OffscaleError } from './client';
|
||||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||||
import { normalizeSshHost } from './ssh';
|
import { normalizeSshHost } from './ssh';
|
||||||
@@ -56,12 +56,12 @@ async function validateServer(url: string, apiKey: string): Promise<string | Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
|
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
|
||||||
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
|
// clients use to test a key. A wrong key surfaces here as OffscaleError(502, 'rejected the stored key').
|
||||||
const client = createClient({ id: 0, name: 'probe', url, apiKey });
|
const client = createClient({ id: 0, name: 'probe', url, apiKey });
|
||||||
try {
|
try {
|
||||||
await client.call('/api/v1/apikey');
|
await client.call('/api/v1/apikey');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HeadscaleError) {
|
if (err instanceof OffscaleError) {
|
||||||
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
|
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -73,7 +73,7 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
|||||||
const { req, userId } = ctx;
|
const { req, userId } = ctx;
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
return Response.json({ servers: await listHeadscaleServers(userId) });
|
return Response.json({ servers: await listOffscaleServers(userId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
@@ -94,8 +94,8 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
|||||||
if (validated instanceof Response) return validated;
|
if (validated instanceof Response) return validated;
|
||||||
|
|
||||||
// First registration becomes active, so the owner is never left with servers but none selected.
|
// First registration becomes active, so the owner is never left with servers but none selected.
|
||||||
const existing = await listHeadscaleServers(userId);
|
const existing = await listOffscaleServers(userId);
|
||||||
const server = await createHeadscaleServer({
|
const server = await createOffscaleServer({
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
@@ -115,13 +115,13 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
|||||||
|
|
||||||
if (action === 'activate') {
|
if (action === 'activate') {
|
||||||
if (req.method !== 'POST') return methodNotAllowed();
|
if (req.method !== 'POST') return methodNotAllowed();
|
||||||
const server = await setActiveHeadscaleServer(userId, id);
|
const server = await setActiveOffscaleServer(userId, id);
|
||||||
return server ? Response.json({ server }) : notFound('no such server');
|
return server ? Response.json({ server }) : notFound('no such server');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'health') {
|
if (action === 'health') {
|
||||||
if (req.method !== 'GET') return methodNotAllowed();
|
if (req.method !== 'GET') return methodNotAllowed();
|
||||||
const creds = await getHeadscaleCredentials(userId, id);
|
const creds = await getOffscaleCredentials(userId, id);
|
||||||
if (!creds) return notFound('no such server');
|
if (!creds) return notFound('no such server');
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@@ -132,11 +132,11 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
|||||||
try {
|
try {
|
||||||
await createClient(creds).call('/api/v1/apikey');
|
await createClient(creds).call('/api/v1/apikey');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof HeadscaleError ? err.message : 'upstream error';
|
const message = err instanceof OffscaleError ? err.message : 'upstream error';
|
||||||
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
|
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordHeadscaleProbe(userId, id, probe.version);
|
await recordOffscaleProbe(userId, id, probe.version);
|
||||||
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
|
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
|||||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
if (!body) return badRequest('expected a JSON body');
|
if (!body) return badRequest('expected a JSON body');
|
||||||
|
|
||||||
const current = await getHeadscaleCredentials(userId, id);
|
const current = await getOffscaleCredentials(userId, id);
|
||||||
if (!current) return notFound('no such server');
|
if (!current) return notFound('no such server');
|
||||||
|
|
||||||
let url: string | undefined;
|
let url: string | undefined;
|
||||||
@@ -177,12 +177,12 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
|||||||
if (validated instanceof Response) return validated;
|
if (validated instanceof Response) return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
|
const server = await updateOffscaleServer(userId, id, { name, url, apiKey, sshHost });
|
||||||
return server ? Response.json({ server }) : notFound('no such server');
|
return server ? Response.json({ server }) : notFound('no such server');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
const deleted = await deleteHeadscaleServer(userId, id);
|
const deleted = await deleteOffscaleServer(userId, id);
|
||||||
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
|
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { MIN_MAJOR, MIN_MINOR, MIN_VERSION_LABEL, meetsFloor, parseVersion } from './version';
|
||||||
|
|
||||||
|
// The version floor is the plugin's whole compatibility story: Headscale changed its admin API shape
|
||||||
|
// repeatedly below 0.29, so `meetsFloor` is what stops a server with an incompatible data model being
|
||||||
|
// registered at all. It has no I/O, so the interesting cases are cheap to pin down — and they were not
|
||||||
|
// pinned down at all until now.
|
||||||
|
|
||||||
|
describe('parseVersion', () => {
|
||||||
|
test('reads major.minor from the shapes a server actually reports', () => {
|
||||||
|
expect(parseVersion('0.29.0')).toEqual({ major: 0, minor: 29 });
|
||||||
|
expect(parseVersion('v0.29.0')).toEqual({ major: 0, minor: 29 });
|
||||||
|
expect(parseVersion(' 0.30.1 ')).toEqual({ major: 0, minor: 30 });
|
||||||
|
expect(parseVersion('1.0')).toEqual({ major: 1, minor: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for 'dev', which is what a self-built image reports", () => {
|
||||||
|
// Not an error case. probeVersion turns this into supported:'unknown' rather than a refusal, so that
|
||||||
|
// someone building Headscale from source is not locked out. If this ever returned a version, those
|
||||||
|
// servers would start being REJECTED — the failure would look like a compatibility bug.
|
||||||
|
expect(parseVersion('dev')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null rather than guessing at non-semver', () => {
|
||||||
|
expect(parseVersion('')).toBeNull();
|
||||||
|
expect(parseVersion('unstable')).toBeNull();
|
||||||
|
expect(parseVersion('.29')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('meetsFloor', () => {
|
||||||
|
test('accepts the floor itself and anything above it', () => {
|
||||||
|
expect(meetsFloor({ major: MIN_MAJOR, minor: MIN_MINOR })).toBe(true);
|
||||||
|
expect(meetsFloor({ major: 0, minor: 30 })).toBe(true);
|
||||||
|
expect(meetsFloor({ major: 1, minor: 0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses the releases whose API shape Officer cannot speak', () => {
|
||||||
|
// 0.26 moved identifiers name→numeric, 0.28 collapsed forcedTags/validTags. Supporting these would
|
||||||
|
// mean carrying several incompatible models, which is the cost the floor exists to avoid.
|
||||||
|
expect(meetsFloor({ major: 0, minor: 28 })).toBe(false);
|
||||||
|
expect(meetsFloor({ major: 0, minor: 26 })).toBe(false);
|
||||||
|
expect(meetsFloor({ major: 0, minor: 0 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a higher major wins regardless of minor — 1.0 is not below 0.29', () => {
|
||||||
|
// The bug this guards: comparing minor first makes 1.0 (minor 0) fail against a floor of 0.29, so the
|
||||||
|
// first stable Headscale release would be refused by the plugin as too old.
|
||||||
|
expect(meetsFloor({ major: 1, minor: 0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the advertised label agrees with the numeric floor', () => {
|
||||||
|
// Two constants describing one fact. They drift silently otherwise: the label is what users are told
|
||||||
|
// to install, and the numbers are what actually gates them.
|
||||||
|
expect(MIN_VERSION_LABEL).toBe(`${MIN_MAJOR}.${MIN_MINOR}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||||
import { headscaleSectionPath } from './shared';
|
import { offscaleSectionPath } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useOffscaleServers } from './useOffscaleServers';
|
||||||
import { TerminalView } from 'officerdev';
|
import { TerminalView } from 'officerdev';
|
||||||
import { Button } from './Cards';
|
import { Button } from './Cards';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ const Centred = ({ children }: { children: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ConsoleView = () => {
|
export const ConsoleView = () => {
|
||||||
const { active, isLoading } = useHeadscaleServers();
|
const { active, isLoading } = useOffscaleServers();
|
||||||
|
|
||||||
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
|
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
|
||||||
// a stable callback and an inline arrow would remount its effect on every render.
|
// a stable callback and an inline arrow would remount its effect on every render.
|
||||||
@@ -67,7 +67,7 @@ export const ConsoleView = () => {
|
|||||||
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
|
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to={headscaleSectionPath('servers')}>
|
<Link to={offscaleSectionPath('servers')}>
|
||||||
<Button variant="primary">Go to Servers</Button>
|
<Button variant="primary">Go to Servers</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Centred>
|
</Centred>
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
|
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
|
||||||
import { timeAgo } from './format';
|
import { timeAgo } from './format';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useOffscaleServers } from './useOffscaleServers';
|
||||||
import {
|
import {
|
||||||
useCompanionAction,
|
useCompanionAction,
|
||||||
useCompanionHealth,
|
useCompanionHealth,
|
||||||
useCompanionLogStream,
|
useCompanionLogStream,
|
||||||
useCompanionLogs,
|
useCompanionLogs,
|
||||||
} from './useHeadscaleCompanion';
|
} from './useOffscaleCompanion';
|
||||||
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
|
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
|
||||||
import { ViewShell } from './ViewShell';
|
import { ViewShell } from './ViewShell';
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ const Lifecycle = ({ running }: { running: boolean | null }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DiagnosticsView = () => {
|
export const DiagnosticsView = () => {
|
||||||
const { active } = useHeadscaleServers();
|
const { active } = useOffscaleServers();
|
||||||
const query = useCompanionHealth();
|
const query = useCompanionHealth();
|
||||||
const result = query.data;
|
const result = query.data;
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
|
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
|
||||||
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
|
import type { OffscaleInvite, OffscaleInviteCreated, InviteStatus } from './shared';
|
||||||
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
|
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
|
||||||
import { useHeadscaleInvites } from './useHeadscaleInvites';
|
import { useOffscaleInvites } from './useOffscaleInvites';
|
||||||
import { useHeadscaleUsers } from './useHeadscaleData';
|
import { useOffscaleUsers } from './useOffscaleData';
|
||||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { fullDate, timeAgo, timeUntil } from './format';
|
import { fullDate, timeAgo, timeUntil } from './format';
|
||||||
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
|
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
|
||||||
import { EmptyBody, ViewShell } from './ViewShell';
|
import { EmptyBody, ViewShell } from './ViewShell';
|
||||||
@@ -76,7 +76,7 @@ const InviteQr = ({ url }: { url: string }) => {
|
|||||||
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
|
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void };
|
type InviteLinkPanelProps = { invite: OffscaleInviteCreated; onDismiss: () => void };
|
||||||
|
|
||||||
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
|
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
|
||||||
const [showQr, setShowQr] = useState(true);
|
const [showQr, setShowQr] = useState(true);
|
||||||
@@ -138,11 +138,11 @@ const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void };
|
type CreateInviteFormProps = { onCreated: (invite: OffscaleInviteCreated) => void; onClose: () => void };
|
||||||
|
|
||||||
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
||||||
const { users } = useHeadscaleUsers();
|
const { users } = useOffscaleUsers();
|
||||||
const { create } = useHeadscaleInvites();
|
const { create } = useOffscaleInvites();
|
||||||
const [user, setUser] = useState('');
|
const [user, setUser] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
|
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
|
||||||
@@ -171,7 +171,7 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
|||||||
onCreated(invite);
|
onCreated(invite);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(headscaleErrorMessage(err));
|
setError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,17 +266,17 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void };
|
type InviteRowProps = { invite: OffscaleInvite; onError: (message: string) => void };
|
||||||
|
|
||||||
const InviteRow = ({ invite, onError }: InviteRowProps) => {
|
const InviteRow = ({ invite, onError }: InviteRowProps) => {
|
||||||
const { revoke } = useHeadscaleInvites();
|
const { revoke } = useOffscaleInvites();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
await revoke.mutateAsync(invite.id);
|
await revoke.mutateAsync(invite.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(headscaleErrorMessage(err));
|
onError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,9 +338,9 @@ const InviteRow = ({ invite, onError }: InviteRowProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const InvitesView = () => {
|
export const InvitesView = () => {
|
||||||
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
|
const { invites, unavailable, isLoading, error } = useOffscaleInvites();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
|
const [created, setCreated] = useState<OffscaleInviteCreated | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const pending = invites.filter((i) => i.status === 'pending').length;
|
const pending = invites.filter((i) => i.status === 'pending').length;
|
||||||
|
|||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
|
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
|
||||||
import type { HeadscalePreAuthKey } from './shared';
|
import type { OffscalePreAuthKey } from './shared';
|
||||||
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
|
import { useOffscaleKeys, useOffscaleUsers } from './useOffscaleData';
|
||||||
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
|
import { useOffscaleServers, offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { timeAgo, timeUntil, fullDate } from './format';
|
import { timeAgo, timeUntil, fullDate } from './format';
|
||||||
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
|
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
|
||||||
import { ViewShell, EmptyBody } from './ViewShell';
|
import { ViewShell, EmptyBody } from './ViewShell';
|
||||||
@@ -102,8 +102,8 @@ const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
|
|||||||
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
|
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
|
||||||
|
|
||||||
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
||||||
const { users } = useHeadscaleUsers();
|
const { users } = useOffscaleUsers();
|
||||||
const { create } = useHeadscaleKeys();
|
const { create } = useOffscaleKeys();
|
||||||
const [userId, setUserId] = useState('');
|
const [userId, setUserId] = useState('');
|
||||||
const [reusable, setReusable] = useState(false);
|
const [reusable, setReusable] = useState(false);
|
||||||
const [ephemeral, setEphemeral] = useState(false);
|
const [ephemeral, setEphemeral] = useState(false);
|
||||||
@@ -133,7 +133,7 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
|||||||
if (result.key.key) onCreated(result.key.key);
|
if (result.key.key) onCreated(result.key.key);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(headscaleErrorMessage(err));
|
setError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,10 +203,10 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void };
|
type KeyRowProps = { entry: OffscalePreAuthKey; onError: (message: string) => void };
|
||||||
|
|
||||||
const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
||||||
const { expire, remove } = useHeadscaleKeys();
|
const { expire, remove } = useOffscaleKeys();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const busy = expire.isPending || remove.isPending;
|
const busy = expire.isPending || remove.isPending;
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(headscaleErrorMessage(err));
|
onError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,8 +271,8 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KeysView = () => {
|
export const KeysView = () => {
|
||||||
const { keys, isLoading, error } = useHeadscaleKeys();
|
const { keys, isLoading, error } = useOffscaleKeys();
|
||||||
const { active } = useHeadscaleServers();
|
const { active } = useOffscaleServers();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [secret, setSecret] = useState<string | null>(null);
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<StatusFilter>('active');
|
const [filter, setFilter] = useState<StatusFilter>('active');
|
||||||
|
|||||||
+11
-11
@@ -14,9 +14,9 @@ import {
|
|||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { HeadscaleNode } from './shared';
|
import type { OffscaleNode } from './shared';
|
||||||
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
|
import { useOffscaleNodes, useOffscaleUsers } from './useOffscaleData';
|
||||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { timeAgo, timeUntil, fullDate } from './format';
|
import { timeAgo, timeUntil, fullDate } from './format';
|
||||||
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
|
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||||
import { ViewShell, EmptyBody } from './ViewShell';
|
import { ViewShell, EmptyBody } from './ViewShell';
|
||||||
@@ -72,7 +72,7 @@ const parseTags = (text: string): string[] => {
|
|||||||
/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */
|
/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */
|
||||||
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t));
|
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t));
|
||||||
|
|
||||||
type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void };
|
type OwnershipProps = { node: OffscaleNode; busy: boolean; onError: (message: string) => void };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit
|
* Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit
|
||||||
@@ -83,8 +83,8 @@ type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: s
|
|||||||
* shared with the Users section, so an expanded card is usually a cache hit anyway.
|
* shared with the Users section, so an expanded card is usually a cache hit anyway.
|
||||||
*/
|
*/
|
||||||
const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
||||||
const { setTags, moveToUser } = useHeadscaleNodes();
|
const { setTags, moveToUser } = useOffscaleNodes();
|
||||||
const { users } = useHeadscaleUsers();
|
const { users } = useOffscaleUsers();
|
||||||
|
|
||||||
const [owner, setOwner] = useState(node.user?.id ?? '');
|
const [owner, setOwner] = useState(node.user?.id ?? '');
|
||||||
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
|
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
|
||||||
@@ -99,7 +99,7 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(headscaleErrorMessage(err));
|
onError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,10 +185,10 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
|
type NodeCardProps = { node: OffscaleNode; onError: (message: string) => void };
|
||||||
|
|
||||||
const NodeCard = ({ node, onError }: NodeCardProps) => {
|
const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||||
const { rename, toggleRoute, expire, remove } = useHeadscaleNodes();
|
const { rename, toggleRoute, expire, remove } = useOffscaleNodes();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [draftName, setDraftName] = useState(node.name);
|
const [draftName, setDraftName] = useState(node.name);
|
||||||
@@ -200,7 +200,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(headscaleErrorMessage(err));
|
onError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NodesView = () => {
|
export const NodesView = () => {
|
||||||
const { nodes, isLoading, error } = useHeadscaleNodes();
|
const { nodes, isLoading, error } = useOffscaleNodes();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
|
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
|
||||||
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
|
import { OFFSCALE_SECTIONS, offscaleSectionPath, type OffscaleSectionId } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useOffscaleServers } from './useOffscaleServers';
|
||||||
|
|
||||||
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
|
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
|
||||||
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
// above (OffscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
||||||
//
|
//
|
||||||
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
|
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
|
||||||
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
|
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
|
||||||
// being derived in JS, per the navigation audit's Phase 4.
|
// being derived in JS, per the navigation audit's Phase 4.
|
||||||
|
|
||||||
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
const ICONS: Record<OffscaleSectionId, LucideIcon> = {
|
||||||
servers: Server,
|
servers: Server,
|
||||||
nodes: Laptop,
|
nodes: Laptop,
|
||||||
users: Users,
|
users: Users,
|
||||||
@@ -36,13 +36,13 @@ const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HeadscaleNav = () => {
|
export const OffscaleNav = () => {
|
||||||
const { active } = useHeadscaleServers();
|
const { active } = useOffscaleServers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
<nav className="flex flex-col gap-0.5 px-2 py-3">
|
<nav className="flex flex-col gap-0.5 px-2 py-3">
|
||||||
{HEADSCALE_SECTIONS.map(({ id, label }) => {
|
{OFFSCALE_SECTIONS.map(({ id, label }) => {
|
||||||
// Without an active server the domain sections have nothing to act on, so they are rendered as
|
// Without an active server the domain sections have nothing to act on, so they are rendered as
|
||||||
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
|
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
|
||||||
// useful is worse than no link.
|
// useful is worse than no link.
|
||||||
@@ -56,7 +56,7 @@ export const HeadscaleNav = () => {
|
|||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={id}
|
key={id}
|
||||||
to={headscaleSectionPath(id)}
|
to={offscaleSectionPath(id)}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${ROW} ${
|
`${ROW} ${
|
||||||
isActive
|
isActive
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Check, Network, Plus } from 'lucide-react';
|
import { Check, Network, Plus } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { headscaleSectionPath } from './shared';
|
import { offscaleSectionPath } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useOffscaleServers } from './useOffscaleServers';
|
||||||
|
|
||||||
// Top-left panel of the /headscale workspace: which server everything else acts on.
|
// Top-left panel of the /headscale workspace: which server everything else acts on.
|
||||||
//
|
//
|
||||||
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
|
// It is its own panel rather than a block inside OffscaleNav because the two answer different questions —
|
||||||
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
|
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
|
||||||
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
|
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
|
||||||
// section list below is real links.
|
// section list below is real links.
|
||||||
@@ -13,8 +13,8 @@ import { useHeadscaleServers } from './useHeadscaleServers';
|
|||||||
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
|
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
|
||||||
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
|
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
|
||||||
|
|
||||||
export const HeadscaleServerPicker = () => {
|
export const OffscaleServerPicker = () => {
|
||||||
const { servers, active, activate, isLoading } = useHeadscaleServers();
|
const { servers, active, activate, isLoading } = useOffscaleServers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
@@ -47,7 +47,7 @@ export const HeadscaleServerPicker = () => {
|
|||||||
|
|
||||||
{servers.length === 0 && !isLoading && (
|
{servers.length === 0 && !isLoading && (
|
||||||
<Link
|
<Link
|
||||||
to={headscaleSectionPath('servers')}
|
to={offscaleSectionPath('servers')}
|
||||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useHeadscaleSection } from './useHeadscaleSection';
|
import { useOffscaleSection } from './useOffscaleSection';
|
||||||
import { ServersView } from './ServersView';
|
import { ServersView } from './ServersView';
|
||||||
import { NodesView } from './NodesView';
|
import { NodesView } from './NodesView';
|
||||||
import { UsersView } from './UsersView';
|
import { UsersView } from './UsersView';
|
||||||
@@ -13,8 +13,8 @@ import { ConsoleView } from './ConsoleView';
|
|||||||
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
|
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
|
||||||
// itself through ViewShell, so there is no gating to do here.
|
// itself through ViewShell, so there is no gating to do here.
|
||||||
|
|
||||||
export const HeadscaleView = () => {
|
export const OffscaleView = () => {
|
||||||
const section = useHeadscaleSection();
|
const section = useOffscaleSection();
|
||||||
|
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case 'nodes':
|
case 'nodes':
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Network } from 'lucide-react';
|
import { Network } from 'lucide-react';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useOffscaleServers } from './useOffscaleServers';
|
||||||
import { HEADSCALE_SECTIONS } from './shared';
|
import { OFFSCALE_SECTIONS } from './shared';
|
||||||
import { useHeadscaleSection } from './useHeadscaleSection';
|
import { useOffscaleSection } from './useOffscaleSection';
|
||||||
|
|
||||||
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
|
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
|
||||||
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
|
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
|
||||||
|
|
||||||
export const HeadscaleViewHeader = () => {
|
export const OffscaleViewHeader = () => {
|
||||||
const section = useHeadscaleSection();
|
const section = useOffscaleSection();
|
||||||
const { active } = useHeadscaleServers();
|
const { active } = useOffscaleServers();
|
||||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
const label = OFFSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
|
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
|
||||||
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
|
import { useOffscalePolicyAssist, assistFailure } from './useOffscalePolicy';
|
||||||
import { collapseUnchanged, diffCounts, diffLines } from './diff';
|
import { collapseUnchanged, diffCounts, diffLines } from './diff';
|
||||||
import { Button, Card, ErrorNote } from './Cards';
|
import { Button, Card, ErrorNote } from './Cards';
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ type PolicyAssistantProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
|
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
|
||||||
const assist = useHeadscalePolicyAssist();
|
const assist = useOffscalePolicyAssist();
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
|
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
|
||||||
// a second ask doesn't briefly show the previous answer against the new base.
|
// a second ask doesn't briefly show the previous answer against the new base.
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
|
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
|
||||||
import { timeAgo } from './format';
|
import { timeAgo } from './format';
|
||||||
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
|
import { useOffscalePolicy, policySaveFailure, type PolicySaveFailure } from './useOffscalePolicy';
|
||||||
import { PolicyAssistant } from './PolicyAssistant';
|
import { PolicyAssistant } from './PolicyAssistant';
|
||||||
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
|
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
|
||||||
import { ViewShell } from './ViewShell';
|
import { ViewShell } from './ViewShell';
|
||||||
@@ -71,7 +71,7 @@ const Rejected = ({ message }: { message: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const PolicyView = () => {
|
export const PolicyView = () => {
|
||||||
const { policy, isLoading, error, save } = useHeadscalePolicy();
|
const { policy, isLoading, error, save } = useOffscalePolicy();
|
||||||
|
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|||||||
+10
-10
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, Terminal, Check, X } from 'lucide-react';
|
import { Loader2, Terminal, Check, X } from 'lucide-react';
|
||||||
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
|
import type { OffscaleServer, OffscaleSshTest } from './shared';
|
||||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
import { MIN_OFFSCALE_VERSION } from './shared';
|
||||||
import { useHeadscaleServers, useHeadscaleSshTest, headscaleErrorMessage } from './useHeadscaleServers';
|
import { useOffscaleServers, useOffscaleSshTest, offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { Card, Button, Field, ErrorNote } from './Cards';
|
import { Card, Button, Field, ErrorNote } from './Cards';
|
||||||
|
|
||||||
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
||||||
@@ -28,11 +28,11 @@ function urlHost(url: string): string | null {
|
|||||||
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
|
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
|
||||||
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
|
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
|
type ServerFormProps = { server?: OffscaleServer | null; onClose: () => void };
|
||||||
|
|
||||||
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||||
const { register, update } = useHeadscaleServers();
|
const { register, update } = useOffscaleServers();
|
||||||
const sshTest = useHeadscaleSshTest();
|
const sshTest = useOffscaleSshTest();
|
||||||
const editing = !!server;
|
const editing = !!server;
|
||||||
|
|
||||||
const [name, setName] = useState(server?.name ?? '');
|
const [name, setName] = useState(server?.name ?? '');
|
||||||
@@ -40,7 +40,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
|||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
|
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
|
const [sshResult, setSshResult] = useState<OffscaleSshTest | null>(null);
|
||||||
|
|
||||||
const mutation = editing ? update : register;
|
const mutation = editing ? update : register;
|
||||||
const pending = mutation.isPending;
|
const pending = mutation.isPending;
|
||||||
@@ -64,7 +64,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
|||||||
try {
|
try {
|
||||||
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
|
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
|
setSshResult({ ok: false, error: offscaleErrorMessage(err), ms: 0 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
|||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(headscaleErrorMessage(err));
|
setError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={edit(setUrl)}
|
onChange={edit(setUrl)}
|
||||||
placeholder="https://headscale.example.com"
|
placeholder="https://headscale.example.com"
|
||||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
|
hint={`The control server's base URL. Officer requires Headscale ${MIN_OFFSCALE_VERSION} or newer.`}
|
||||||
autoFocus={!editing}
|
autoFocus={!editing}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
+15
-15
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
||||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
import type { OffscaleServer, OffscaleHealth } from './shared';
|
||||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
import { MIN_OFFSCALE_VERSION } from './shared';
|
||||||
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
|
import { useOffscaleServers, useOffscaleHealth, offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
|
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||||
import { ServerForm } from './ServerForm';
|
import { ServerForm } from './ServerForm';
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ function timeAgo(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerRowProps = {
|
type ServerRowProps = {
|
||||||
server: HeadscaleServer;
|
server: OffscaleServer;
|
||||||
health: HeadscaleHealth | undefined;
|
health: OffscaleHealth | undefined;
|
||||||
testing: boolean;
|
testing: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
@@ -70,7 +70,7 @@ const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit,
|
|||||||
{health?.ok && health.supported === 'unknown' && (
|
{health?.ok && health.supported === 'unknown' && (
|
||||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
||||||
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
|
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
|
||||||
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
{MIN_OFFSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
||||||
builds.
|
builds.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -121,7 +121,7 @@ const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
|
|||||||
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
|
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
|
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
|
||||||
supports Headscale {MIN_HEADSCALE_VERSION} and newer.
|
supports Headscale {MIN_OFFSCALE_VERSION} and newer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={onRegister}>
|
<Button variant="primary" onClick={onRegister}>
|
||||||
@@ -132,11 +132,11 @@ const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ServersView = () => {
|
export const ServersView = () => {
|
||||||
const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
|
const { servers, isLoading, error, refetch, activate, remove } = useOffscaleServers();
|
||||||
const healthProbe = useHeadscaleHealth();
|
const healthProbe = useOffscaleHealth();
|
||||||
|
|
||||||
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
|
const [formFor, setFormFor] = useState<'new' | OffscaleServer | null>(null);
|
||||||
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
|
const [health, setHealth] = useState<Record<number, OffscaleHealth>>({});
|
||||||
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
|
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
|
||||||
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
|
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
@@ -150,7 +150,7 @@ export const ServersView = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
|
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
|
||||||
// page-level error, which would blame the whole screen for one unreachable box.
|
// page-level error, which would blame the whole screen for one unreachable box.
|
||||||
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
|
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: offscaleErrorMessage(err), ms: 0 } }));
|
||||||
} finally {
|
} finally {
|
||||||
setTestingIds((prev) => prev.filter((t) => t !== id));
|
setTestingIds((prev) => prev.filter((t) => t !== id));
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ export const ServersView = () => {
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(headscaleErrorMessage(err));
|
setActionError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@ export const ServersView = () => {
|
|||||||
<ErrorNote>
|
<ErrorNote>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it
|
Could not reach the Headscale sidecar: {offscaleErrorMessage(error)}. If it is not running, start it
|
||||||
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
|
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-offscale</code>.
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+9
-9
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
|
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
|
||||||
import type { HeadscaleUserWithCounts } from './shared';
|
import type { OffscaleUserWithCounts } from './shared';
|
||||||
import { useHeadscaleUsers } from './useHeadscaleData';
|
import { useOffscaleUsers } from './useOffscaleData';
|
||||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { timeAgo } from './format';
|
import { timeAgo } from './format';
|
||||||
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
|
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
|
||||||
import { ViewShell, EmptyBody } from './ViewShell';
|
import { ViewShell, EmptyBody } from './ViewShell';
|
||||||
@@ -12,10 +12,10 @@ import { ViewShell, EmptyBody } from './ViewShell';
|
|||||||
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
|
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
|
||||||
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
|
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
|
||||||
|
|
||||||
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
|
type UserRowProps = { user: OffscaleUserWithCounts; onError: (message: string) => void };
|
||||||
|
|
||||||
const UserRow = ({ user, onError }: UserRowProps) => {
|
const UserRow = ({ user, onError }: UserRowProps) => {
|
||||||
const { rename, remove } = useHeadscaleUsers();
|
const { rename, remove } = useOffscaleUsers();
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [draft, setDraft] = useState(user.name);
|
const [draft, setDraft] = useState(user.name);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
@@ -26,7 +26,7 @@ const UserRow = ({ user, onError }: UserRowProps) => {
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(headscaleErrorMessage(err));
|
onError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ const UserRow = ({ user, onError }: UserRowProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
||||||
const { create } = useHeadscaleUsers();
|
const { create } = useOffscaleUsers();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -124,7 +124,7 @@ const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
|||||||
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(headscaleErrorMessage(err));
|
setError(offscaleErrorMessage(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UsersView = () => {
|
export const UsersView = () => {
|
||||||
const { users, isLoading, error } = useHeadscaleUsers();
|
const { users, isLoading, error } = useOffscaleUsers();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Loader2, ServerOff } from 'lucide-react';
|
import { Loader2, ServerOff } from 'lucide-react';
|
||||||
import { NO_ACTIVE_SERVER } from './shared';
|
import { NO_ACTIVE_SERVER } from './shared';
|
||||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||||
import { ErrorNote } from './Cards';
|
import { ErrorNote } from './Cards';
|
||||||
|
|
||||||
// The loading / no-server / failed states every domain section shares.
|
// The loading / no-server / failed states every domain section shares.
|
||||||
@@ -56,7 +56,7 @@ export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps)
|
|||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ErrorNote>
|
<ErrorNote>
|
||||||
Could not load {label}: {headscaleErrorMessage(error)}
|
Could not load {label}: {offscaleErrorMessage(error)}
|
||||||
</ErrorNote>
|
</ErrorNote>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+4
-4
@@ -8,15 +8,15 @@ export const defaultLayout: LayoutNode = {
|
|||||||
{
|
{
|
||||||
node: {
|
node: {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'headscale-sidebar',
|
id: 'offscale-sidebar',
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
|
{ node: { type: 'panel', id: 'offscale-servers', appType: 'offscale-servers' }, size: 30 },
|
||||||
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
|
{ node: { type: 'panel', id: 'offscale-nav', appType: 'offscale-nav' }, size: 70 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: 22,
|
size: 22,
|
||||||
},
|
},
|
||||||
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
|
{ node: { type: 'panel', id: 'offscale-view', appType: 'offscale-view' }, size: 78 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-11
@@ -1,27 +1,29 @@
|
|||||||
import type { AppRegistryMeta } from 'officerdev';
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||||
import { HeadscaleNav } from './HeadscaleNav';
|
import { OffscaleNav } from './OffscaleNav';
|
||||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
import { OffscaleServerPicker } from './OffscaleServerPicker';
|
||||||
import { HeadscaleView } from './HeadscaleView';
|
import { OffscaleView } from './OffscaleView';
|
||||||
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
|
import { OffscaleViewHeader } from './OffscaleViewHeader';
|
||||||
|
|
||||||
export { HeadscaleNav, HeadscaleServerPicker, HeadscaleView };
|
// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is
|
||||||
|
// no way to export a component from a plugin, which is the rule this file exists to keep. The three
|
||||||
|
// re-exports that were here were residue of the platform importing them directly before extraction.
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'headscale-servers',
|
key: 'offscale-servers',
|
||||||
name: 'Headscale servers',
|
name: 'Headscale servers',
|
||||||
icon: Network,
|
icon: Network,
|
||||||
component: HeadscaleServerPicker,
|
component: OffscaleServerPicker,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
|
{ key: 'offscale-nav', name: 'Headscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
|
||||||
{
|
{
|
||||||
key: 'headscale-view',
|
key: 'offscale-view',
|
||||||
name: 'Headscale',
|
name: 'Headscale',
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
component: HeadscaleView,
|
component: OffscaleView,
|
||||||
header: HeadscaleViewHeader,
|
header: OffscaleViewHeader,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+24
-24
@@ -1,10 +1,10 @@
|
|||||||
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
||||||
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
|
// officer-offscale sidecar returns under /api/offscale/_officer/* — deliberately NOT Headscale's own API
|
||||||
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
|
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
|
||||||
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
|
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
|
||||||
// version. See src/servers/sidecar/headscale/routes.ts.
|
// version. See ../sidecar/routes.ts, and ../OFFSCALE_API.md for the published contract.
|
||||||
|
|
||||||
export const HEADSCALE_SECTIONS = [
|
export const OFFSCALE_SECTIONS = [
|
||||||
{ id: 'servers', label: 'Servers' },
|
{ id: 'servers', label: 'Servers' },
|
||||||
{ id: 'nodes', label: 'Nodes' },
|
{ id: 'nodes', label: 'Nodes' },
|
||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
@@ -15,19 +15,19 @@ export const HEADSCALE_SECTIONS = [
|
|||||||
{ id: 'console', label: 'Console' },
|
{ id: 'console', label: 'Console' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
|
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
|
||||||
|
|
||||||
/** Where /headscale lands, and where an unrecognised section redirects to. */
|
/** Where /headscale lands, and where an unrecognised section redirects to. */
|
||||||
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
|
export const DEFAULT_OFFSCALE_SECTION: OffscaleSectionId = 'servers';
|
||||||
|
|
||||||
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
|
export const isOffscaleSection = (value: string | undefined): value is OffscaleSectionId =>
|
||||||
HEADSCALE_SECTIONS.some((s) => s.id === value);
|
OFFSCALE_SECTIONS.some((s) => s.id === value);
|
||||||
|
|
||||||
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
|
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
|
||||||
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
|
export const offscaleSectionPath = (id: OffscaleSectionId) => `/offscale/${id}`;
|
||||||
|
|
||||||
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
|
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
|
||||||
export type HeadscaleServer = {
|
export type OffscaleServer = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -44,7 +44,7 @@ export type HeadscaleServer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
|
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
|
||||||
export type HeadscaleHealth = {
|
export type OffscaleHealth = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
version?: string;
|
version?: string;
|
||||||
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
|
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
|
||||||
@@ -54,10 +54,10 @@ export type HeadscaleHealth = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
|
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
|
||||||
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
|
export type OffscaleSshTest = { ok: boolean; error?: string; ms: number };
|
||||||
|
|
||||||
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
|
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
|
||||||
export const MIN_HEADSCALE_VERSION = '0.29';
|
export const MIN_OFFSCALE_VERSION = '0.29';
|
||||||
|
|
||||||
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
|
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export const MIN_HEADSCALE_VERSION = '0.29';
|
|||||||
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
|
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
|
||||||
* because there is nothing on the server that reports one. Only an attempted save finds out.
|
* because there is nothing on the server that reports one. Only an attempted save finds out.
|
||||||
*/
|
*/
|
||||||
export type HeadscalePolicy = {
|
export type OffscalePolicy = {
|
||||||
policy: string;
|
policy: string;
|
||||||
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
|
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -81,7 +81,7 @@ export const POLICY_REJECTED = 'policy_rejected';
|
|||||||
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
|
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
|
||||||
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
|
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
|
||||||
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
|
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
|
||||||
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
|
// same domain is independent and may still work. Contract: COMMS/OFFSCALE_COMPANION_API.md.
|
||||||
|
|
||||||
/** Never available for a companion that is missing — the reason says which flavour of missing. */
|
/** Never available for a companion that is missing — the reason says which flavour of missing. */
|
||||||
type Unavailable = { available: false; reason: string };
|
type Unavailable = { available: false; reason: string };
|
||||||
@@ -128,7 +128,7 @@ export type CompanionActionResult =
|
|||||||
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
||||||
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
|
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
|
||||||
|
|
||||||
export type HeadscaleUser = {
|
export type OffscaleUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
@@ -138,13 +138,13 @@ export type HeadscaleUser = {
|
|||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
|
export type OffscaleUserWithCounts = OffscaleUser & { nodeCount: number; onlineCount: number };
|
||||||
|
|
||||||
export type HeadscaleNode = {
|
export type OffscaleNode = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
user: HeadscaleUser | null;
|
user: OffscaleUser | null;
|
||||||
ipAddresses: string[];
|
ipAddresses: string[];
|
||||||
online: boolean;
|
online: boolean;
|
||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
@@ -162,13 +162,13 @@ export type HeadscaleNode = {
|
|||||||
isExitNode: boolean;
|
isExitNode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeadscalePreAuthKey = {
|
export type OffscalePreAuthKey = {
|
||||||
id: string;
|
id: string;
|
||||||
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
|
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
|
||||||
key: string | null;
|
key: string | null;
|
||||||
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
|
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
|
||||||
keyDisplay: string;
|
keyDisplay: string;
|
||||||
user: HeadscaleUser | null;
|
user: OffscaleUser | null;
|
||||||
reusable: boolean;
|
reusable: boolean;
|
||||||
ephemeral: boolean;
|
ephemeral: boolean;
|
||||||
used: boolean;
|
used: boolean;
|
||||||
@@ -189,7 +189,7 @@ export const NO_ACTIVE_SERVER = 'no_active_server';
|
|||||||
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
|
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
|
||||||
|
|
||||||
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
|
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
|
||||||
export type HeadscaleInvite = {
|
export type OffscaleInvite = {
|
||||||
id: string;
|
id: string;
|
||||||
user: string;
|
user: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
@@ -206,7 +206,7 @@ export type HeadscaleInvite = {
|
|||||||
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
|
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
|
||||||
* component state, shown once, and never written to a cache, a query key or a log.
|
* component state, shown once, and never written to a cache, a query key or a log.
|
||||||
*/
|
*/
|
||||||
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
|
export type OffscaleInviteCreated = OffscaleInvite & { url: string };
|
||||||
|
|
||||||
export type InviteCreateInput = {
|
export type InviteCreateInput = {
|
||||||
user: string;
|
user: string;
|
||||||
@@ -216,8 +216,8 @@ export type InviteCreateInput = {
|
|||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
|
export type InvitesListResult = { available: true; invites: OffscaleInvite[] } | Unavailable;
|
||||||
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
|
export type InviteCreateResult = { available: true; invite: OffscaleInviteCreated } | Unavailable;
|
||||||
|
|
||||||
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
|
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
|
||||||
export const INVITE_TTL_MIN_SECONDS = 60;
|
export const INVITE_TTL_MIN_SECONDS = 60;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
|||||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||||
|
|
||||||
const BASE = '/offscale/_officer/companion';
|
const BASE = '/offscale/_officer/companion';
|
||||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
const HEALTH_KEY = ['offscale', 'companion', 'health'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The container's health, polled.
|
* The container's health, polled.
|
||||||
@@ -32,7 +32,7 @@ export function useCompanionHealth() {
|
|||||||
export function useCompanionLogs(tail: number, enabled: boolean) {
|
export function useCompanionLogs(tail: number, enabled: boolean) {
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['headscale', 'companion', 'logs', tail],
|
queryKey: ['offscale', 'companion', 'logs', tail],
|
||||||
queryFn: () => get<CompanionLogsResult>(`${BASE}/logs?tail=${tail}`),
|
queryFn: () => get<CompanionLogsResult>(`${BASE}/logs?tail=${tail}`),
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared';
|
import type { OffscaleNode, OffscaleUserWithCounts, OffscalePreAuthKey } from './shared';
|
||||||
|
|
||||||
// Queries for the domain sections. All three act on whichever server is active, so they live under the
|
// Queries for the domain sections. All three act on whichever server is active, so they live under the
|
||||||
// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers).
|
// same ['offscale'] key prefix that switching servers invalidates wholesale (see useOffscaleServers).
|
||||||
//
|
//
|
||||||
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
|
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
|
||||||
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
|
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
|
||||||
// are small and the correctness is worth more than the round trip.
|
// are small and the correctness is worth more than the round trip.
|
||||||
|
|
||||||
const NODES_KEY = ['headscale', 'nodes'] as const;
|
const NODES_KEY = ['offscale', 'nodes'] as const;
|
||||||
const USERS_KEY = ['headscale', 'users'] as const;
|
const USERS_KEY = ['offscale', 'users'] as const;
|
||||||
const KEYS_KEY = ['headscale', 'keys'] as const;
|
const KEYS_KEY = ['offscale', 'keys'] as const;
|
||||||
|
|
||||||
const EMPTY_NODES: HeadscaleNode[] = [];
|
const EMPTY_NODES: OffscaleNode[] = [];
|
||||||
const EMPTY_USERS: HeadscaleUserWithCounts[] = [];
|
const EMPTY_USERS: OffscaleUserWithCounts[] = [];
|
||||||
const EMPTY_KEYS: HeadscalePreAuthKey[] = [];
|
const EMPTY_KEYS: OffscalePreAuthKey[] = [];
|
||||||
|
|
||||||
export function useHeadscaleNodes() {
|
export function useOffscaleNodes() {
|
||||||
const { get, post, delete: del } = useClient();
|
const { get, post, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['offscale'] });
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: NODES_KEY,
|
queryKey: NODES_KEY,
|
||||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
queryFn: () => get<{ nodes: OffscaleNode[] }>('/offscale/_officer/nodes'),
|
||||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||||
refetchInterval: 20_000,
|
refetchInterval: 20_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
@@ -78,14 +78,14 @@ export function useHeadscaleNodes() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHeadscaleUsers() {
|
export function useOffscaleUsers() {
|
||||||
const { get, post, delete: del } = useClient();
|
const { get, post, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['offscale'] });
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: USERS_KEY,
|
queryKey: USERS_KEY,
|
||||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
queryFn: () => get<{ users: OffscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,14 +123,14 @@ export type CreateKeyInput = {
|
|||||||
aclTags: string[];
|
aclTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useHeadscaleKeys() {
|
export function useOffscaleKeys() {
|
||||||
const { get, post, delete: del } = useClient();
|
const { get, post, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
|
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: KEYS_KEY,
|
queryKey: KEYS_KEY,
|
||||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
queryFn: () => get<{ keys: OffscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export function useHeadscaleKeys() {
|
|||||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: CreateKeyInput) =>
|
mutationFn: (input: CreateKeyInput) =>
|
||||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
post<{ key: OffscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
|
import type { OffscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
|
||||||
|
|
||||||
// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the
|
// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the
|
||||||
// server's own companion — see src/servers/sidecar/headscale/invites.ts for why the records live there and
|
// server's own companion — see ../sidecar/invites.ts for why the records live there and
|
||||||
// not here.
|
// not here.
|
||||||
//
|
//
|
||||||
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
|
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
|
||||||
@@ -11,11 +11,11 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
|||||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||||
|
|
||||||
const BASE = '/offscale/_officer/enroll/invites';
|
const BASE = '/offscale/_officer/enroll/invites';
|
||||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
const INVITES_KEY = ['offscale', 'invites'] as const;
|
||||||
|
|
||||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||||
|
|
||||||
export function useHeadscaleInvites() {
|
export function useOffscaleInvites() {
|
||||||
const { get, post, delete: del } = useClient();
|
const { get, post, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
|
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
|
||||||
@@ -30,7 +30,7 @@ export function useHeadscaleInvites() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async (input: InviteCreateInput): Promise<HeadscaleInviteCreated> => {
|
mutationFn: async (input: InviteCreateInput): Promise<OffscaleInviteCreated> => {
|
||||||
const result = await post<InviteCreateResult>(BASE, input);
|
const result = await post<InviteCreateResult>(BASE, input);
|
||||||
// An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the
|
// An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the
|
||||||
// form shows it where the admin is looking rather than rendering an empty link panel.
|
// form shows it where the admin is looking rather than rendering an empty link panel.
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { HeadscalePolicy } from './shared';
|
import type { OffscalePolicy } from './shared';
|
||||||
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
||||||
|
|
||||||
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
|
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
|
||||||
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
|
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
|
||||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||||
|
|
||||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
const POLICY_KEY = ['offscale', 'policy'] as const;
|
||||||
const PATH = '/offscale/_officer/policy';
|
const PATH = '/offscale/_officer/policy';
|
||||||
|
|
||||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||||
@@ -45,23 +45,23 @@ export function assistFailure(err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask for a revised policy in English. Separate from `useHeadscalePolicy` because it is a different kind of
|
* Ask for a revised policy in English. Separate from `useOffscalePolicy` because it is a different kind of
|
||||||
* thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload.
|
* thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload.
|
||||||
*/
|
*/
|
||||||
export function useHeadscalePolicyAssist() {
|
export function useOffscalePolicyAssist() {
|
||||||
const { post } = useClient();
|
const { post } = useClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: { prompt: string; policy: string }) => post<PolicyProposal>(`${PATH}/assist`, input),
|
mutationFn: (input: { prompt: string; policy: string }) => post<PolicyProposal>(`${PATH}/assist`, input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHeadscalePolicy() {
|
export function useOffscalePolicy() {
|
||||||
const { get, put } = useClient();
|
const { get, put } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: POLICY_KEY,
|
queryKey: POLICY_KEY,
|
||||||
queryFn: () => get<HeadscalePolicy>(PATH),
|
queryFn: () => get<OffscalePolicy>(PATH),
|
||||||
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
|
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
|
||||||
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
|
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
@@ -69,7 +69,7 @@ export function useHeadscalePolicy() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (policy: string) => put<HeadscalePolicy>(PATH, { policy }),
|
mutationFn: (policy: string) => put<OffscalePolicy>(PATH, { policy }),
|
||||||
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
|
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
|
||||||
// own state and could show the pre-save document for a frame.
|
// own state and could show the pre-save document for a frame.
|
||||||
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
|
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
|
import { DEFAULT_OFFSCALE_SECTION, isOffscaleSection, type OffscaleSectionId } from './shared';
|
||||||
|
|
||||||
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
|
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
|
||||||
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
|
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
|
||||||
// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
|
// opened in a new tab, or reached with the back button. OffscaleScreen redirects anything unrecognised, so the
|
||||||
// fallback here is only for the instant before that lands.
|
// fallback here is only for the instant before that lands.
|
||||||
|
|
||||||
export function useHeadscaleSection(): HeadscaleSectionId {
|
export function useOffscaleSection(): OffscaleSectionId {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
|
return isOffscaleSection(section) ? section : DEFAULT_OFFSCALE_SECTION;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './shared';
|
import type { OffscaleServer, OffscaleHealth, OffscaleSshTest } from './shared';
|
||||||
|
|
||||||
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
||||||
// the active server anywhere updates the whole screen at once.
|
// the active server anywhere updates the whole screen at once.
|
||||||
@@ -9,8 +9,8 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
|||||||
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
|
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
|
||||||
// exceptional here, so the mutations surface their message rather than swallowing it.
|
// exceptional here, so the mutations surface their message rather than swallowing it.
|
||||||
|
|
||||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
const SERVERS_KEY = ['offscale', 'servers'] as const;
|
||||||
const EMPTY: HeadscaleServer[] = [];
|
const EMPTY: OffscaleServer[] = [];
|
||||||
|
|
||||||
const BASE = '/offscale/_officer/servers';
|
const BASE = '/offscale/_officer/servers';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const BASE = '/offscale/_officer/servers';
|
|||||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||||
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
|
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
|
||||||
*/
|
*/
|
||||||
export function headscaleErrorMessage(err: unknown): string {
|
export function offscaleErrorMessage(err: unknown): string {
|
||||||
const raw = (err as { message?: unknown } | null)?.message;
|
const raw = (err as { message?: unknown } | null)?.message;
|
||||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||||
try {
|
try {
|
||||||
@@ -34,25 +34,25 @@ export type RegisterServerInput = { name?: string; url: string; apiKey: string;
|
|||||||
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
|
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
|
||||||
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: string };
|
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: string };
|
||||||
|
|
||||||
export function useHeadscaleServers() {
|
export function useOffscaleServers() {
|
||||||
const { get, post, patch, delete: del } = useClient();
|
const { get, post, patch, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: SERVERS_KEY,
|
queryKey: SERVERS_KEY,
|
||||||
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
|
queryFn: () => get<{ servers: OffscaleServer[] }>(BASE),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
|
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
|
||||||
|
|
||||||
const register = useMutation({
|
const register = useMutation({
|
||||||
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
|
mutationFn: (input: RegisterServerInput) => post<{ server: OffscaleServer }>(BASE, input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
|
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: OffscaleServer }>(`${BASE}/${id}`, rest),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ export function useHeadscaleServers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activate = useMutation({
|
const activate = useMutation({
|
||||||
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
|
mutationFn: (id: number) => post<{ server: OffscaleServer }>(`${BASE}/${id}/activate`),
|
||||||
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
|
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['offscale'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const servers = query.data?.servers ?? EMPTY;
|
const servers = query.data?.servers ?? EMPTY;
|
||||||
@@ -86,17 +86,17 @@ export function useHeadscaleServers() {
|
|||||||
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
|
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
|
||||||
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
|
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
|
||||||
*/
|
*/
|
||||||
export function useHeadscaleSshTest() {
|
export function useOffscaleSshTest() {
|
||||||
const { post } = useClient();
|
const { post } = useClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
mutationFn: (host: string) => post<OffscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
|
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
|
||||||
export function useHeadscaleHealth() {
|
export function useOffscaleHealth() {
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
|
mutationFn: (id: number) => get<OffscaleHealth>(`${BASE}/${id}/health`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user