Files
pastilhas 95b84ea748 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.
2026-08-15 18:41:52 +00:00

227 lines
9.3 KiB
TypeScript

import { useState } from 'react';
import { Loader2, Terminal, Check, X } from 'lucide-react';
import type { OffscaleServer, OffscaleSshTest } from './shared';
import { MIN_OFFSCALE_VERSION } from './shared';
import { useOffscaleServers, useOffscaleSshTest, offscaleErrorMessage } from './useOffscaleServers';
import { Card, Button, Field, ErrorNote } from './Cards';
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
// a pending state saying what is being checked, and the server's own reason inline on rejection.
//
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
//
// The SSH host is the odd one out: it is NOT validated on save. A control server that is down is exactly when
// you want the console, so refusing to save the escape hatch because the machine is unreachable would be
// precisely backwards. Test is a separate, explicit button.
/** The host part of the control-server URL, for the "you have typed the same machine" warning. */
function urlHost(url: string): string | null {
try {
return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase();
} catch {
return null;
}
}
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
type ServerFormProps = { server?: OffscaleServer | null; onClose: () => void };
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const { register, update } = useOffscaleServers();
const sshTest = useOffscaleSshTest();
const editing = !!server;
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
const [apiKey, setApiKey] = useState('');
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
const [error, setError] = useState<string | null>(null);
const [sshResult, setSshResult] = useState<OffscaleSshTest | null>(null);
const mutation = editing ? update : register;
const pending = mutation.isPending;
// A rejection leaves its reason under the button, and the reason is about values that have since been
// corrected. Editing anything clears it, so a stale message can never make a live form look dead.
const edit =
<T,>(set: (value: T) => void) =>
(value: T) => {
set(value);
setError(null);
};
// The point of a separate SSH address is reaching the box when the tailnet or Headscale itself is down. If
// it resolves through the same name the control server does, it goes down with it — which is the one thing
// this field is supposed to survive.
const sameAsControl = !!sshHost.trim() && !!urlHost(url) && sshTarget(sshHost) === urlHost(url);
const runSshTest = async () => {
setSshResult(null);
try {
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
} catch (err) {
setSshResult({ ok: false, error: offscaleErrorMessage(err), ms: 0 });
}
};
const submit = async () => {
if (pending) return;
setError(null);
// Drop the previous rejection from the mutation too — this is a fresh attempt, not a retry of that one.
mutation.reset();
if (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required');
try {
if (editing && server) {
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
await update.mutateAsync({
id: server.id,
name: name.trim() || undefined,
url: url.trim() === server.url ? undefined : url.trim(),
apiKey: apiKey.trim() || undefined,
// '' is meaningful here — it clears the console target — so this is sent whenever it differs.
sshHost: sshHost.trim() === (server.sshHost ?? '') ? undefined : sshHost.trim(),
});
} else {
await register.mutateAsync({
name: name.trim() || undefined,
url: url.trim(),
apiKey: apiKey.trim(),
sshHost: sshHost.trim() || undefined,
});
}
onClose();
} catch (err) {
setError(offscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
</div>
<Field
label="Server URL"
value={url}
onChange={edit(setUrl)}
placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_OFFSCALE_VERSION} or newer.`}
autoFocus={!editing}
/>
<Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey}
onChange={edit(setApiKey)}
type="password"
placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
/>
<Field
label="Name (optional)"
value={name}
onChange={edit(setName)}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-3">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-300">
<Terminal className="h-3.5 w-3.5" />
SSH console (optional)
</div>
<p className="text-[11px] leading-snug text-zinc-500">
The last resort for when the API cannot answer Headscale crashed, the tailnet is down, the logs are the
only evidence. The Console section runs plain <code className="font-mono">ssh</code> here in a terminal,
using the keys already on this machine. Officer stores no password, key or port.
</p>
<Field
label="SSH address"
value={sshHost}
onChange={edit((value: string) => {
setSshHost(value);
setSshResult(null);
})}
placeholder="203.0.113.10 or root@203.0.113.10"
hint="Use the machine's own address, not the Headscale hostname. Leave blank for no console."
/>
{sameAsControl && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
That is the same host as the server URL. If Headscale is what resolves or routes that name, the console
will be unreachable in exactly the situations you would need it. Prefer the machine's raw IP on a path
that does not depend on the tailnet.
</div>
)}
{sshResult && (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[11px] leading-snug ${
sshResult.ok
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/30 bg-red-500/10 text-red-300'
}`}
>
{sshResult.ok ? (
<Check className="mt-px h-3.5 w-3.5 shrink-0" />
) : (
<X className="mt-px h-3.5 w-3.5 shrink-0" />
)}
<span>
{sshResult.ok ? (
<>Connected and ran a command in {sshResult.ms}ms.</>
) : (
<>
{sshResult.error ?? 'Could not connect'}
<span className="mt-1 block text-red-300/70">
The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails
here as “Permission denied”.
</span>
</>
)}
</span>
</div>
)}
<div>
<Button onClick={() => void runSshTest()} disabled={!sshHost.trim() || sshTest.isPending}>
{sshTest.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Terminal className="h-3.5 w-3.5" />
)}
{sshTest.isPending ? 'Connecting' : 'Test connection'}
</Button>
</div>
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={pending}>
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{pending ? 'Verifying' : editing ? 'Save changes' : 'Register server'}
</Button>
<Button onClick={onClose} disabled={pending}>
Cancel
</Button>
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key</span>}
</div>
</form>
</Card>
);
};