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.
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
|
|
import type { OffscalePreAuthKey } from './shared';
|
|
import { useOffscaleKeys, useOffscaleUsers } from './useOffscaleData';
|
|
import { useOffscaleServers, offscaleErrorMessage } from './useOffscaleServers';
|
|
import { timeAgo, timeUntil, fullDate } from './format';
|
|
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
|
|
import { ViewShell, EmptyBody } from './ViewShell';
|
|
import { copyToClipboard } from 'helpers/clipboard';
|
|
|
|
// Pre-auth keys — the tokens a machine presents to join the tailnet.
|
|
//
|
|
// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the
|
|
// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the
|
|
// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy
|
|
// and the ready-to-paste join command, and only clears on an explicit dismiss.
|
|
//
|
|
// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones.
|
|
|
|
const STATUS_FILTERS = [
|
|
{ id: 'active', label: 'Active' },
|
|
{ id: 'all', label: 'All' },
|
|
] as const;
|
|
|
|
type StatusFilter = (typeof STATUS_FILTERS)[number]['id'];
|
|
|
|
const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
|
|
|
|
const CopyButton = ({ value, label }: { value: string; label: string }) => {
|
|
const [done, setDone] = useState(false);
|
|
const copy = () => {
|
|
void copyToClipboard(value);
|
|
setDone(true);
|
|
window.setTimeout(() => setDone(false), 1500);
|
|
};
|
|
return (
|
|
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
|
|
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
{done ? 'Copied' : label}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
|
|
|
|
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
|
|
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
|
|
return (
|
|
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
|
|
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
|
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
|
|
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
|
|
Headscale stores it hashed. Once you dismiss this, nothing — not Officer, not the server — can show it
|
|
again.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-3 p-4">
|
|
<div>
|
|
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
|
|
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
|
|
{secret}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
|
|
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
|
|
{command}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<CopyButton value={secret} label="Copy key" />
|
|
<CopyButton value={command} label="Copy command" />
|
|
<Button variant="danger" onClick={onDismiss}>
|
|
I've saved it
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
|
|
|
|
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
|
|
<label className="flex cursor-pointer items-start gap-2.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(ev) => onChange(ev.target.checked)}
|
|
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="block text-xs font-medium text-zinc-300">{label}</span>
|
|
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
|
|
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
|
|
|
|
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
|
const { users } = useOffscaleUsers();
|
|
const { create } = useOffscaleKeys();
|
|
const [userId, setUserId] = useState('');
|
|
const [reusable, setReusable] = useState(false);
|
|
const [ephemeral, setEphemeral] = useState(false);
|
|
const [days, setDays] = useState('90');
|
|
const [tags, setTags] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const submit = async () => {
|
|
setError(null);
|
|
const chosen = userId || users[0]?.id;
|
|
if (!chosen) return setError('Create a user first — every key belongs to one.');
|
|
const expirationDays = Number(days);
|
|
if (!Number.isFinite(expirationDays) || expirationDays <= 0)
|
|
return setError('Expiry must be a positive number of days');
|
|
|
|
try {
|
|
const result = await create.mutateAsync({
|
|
userId: chosen,
|
|
reusable,
|
|
ephemeral,
|
|
expirationDays,
|
|
aclTags: tags
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter(Boolean),
|
|
});
|
|
if (result.key.key) onCreated(result.key.key);
|
|
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">New pre-auth key</div>
|
|
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-zinc-400">User</span>
|
|
<select
|
|
value={userId || users[0]?.id || ''}
|
|
onChange={(ev) => setUserId(ev.target.value)}
|
|
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
|
|
>
|
|
{users.map((user) => (
|
|
<option key={user.id} value={user.id}>
|
|
{user.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Toggle
|
|
checked={reusable}
|
|
onChange={setReusable}
|
|
label="Reusable"
|
|
hint="Any number of machines can join with it, until it expires."
|
|
/>
|
|
<Toggle
|
|
checked={ephemeral}
|
|
onChange={setEphemeral}
|
|
label="Ephemeral"
|
|
hint="Nodes that join with it are removed when they go offline. For containers and CI."
|
|
/>
|
|
</div>
|
|
|
|
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
|
|
<Field
|
|
label="ACL tags (optional)"
|
|
value={tags}
|
|
onChange={setTags}
|
|
placeholder="server, ci"
|
|
hint="Comma separated. The tag: prefix is added for you."
|
|
/>
|
|
|
|
{error && <ErrorNote>{error}</ErrorNote>}
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Button type="submit" variant="primary" disabled={create.isPending}>
|
|
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
Create key
|
|
</Button>
|
|
<Button onClick={onClose} disabled={create.isPending}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
type KeyRowProps = { entry: OffscalePreAuthKey; onError: (message: string) => void };
|
|
|
|
const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
|
const { expire, remove } = useOffscaleKeys();
|
|
const [confirming, setConfirming] = useState(false);
|
|
const busy = expire.isPending || remove.isPending;
|
|
|
|
const run = async (fn: () => Promise<unknown>) => {
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
onError(offscaleErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<div className="flex flex-wrap items-center gap-3 p-3.5">
|
|
<Dot tone={STATUS_TONE[entry.status]} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
|
|
{entry.user && <Badge>{entry.user.name}</Badge>}
|
|
{entry.reusable && <Badge>reusable</Badge>}
|
|
{entry.ephemeral && <Badge>ephemeral</Badge>}
|
|
{entry.aclTags.map((tag) => (
|
|
<Badge key={tag} tone="active">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
|
|
<span>{entry.status}</span>
|
|
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
|
|
<span>· created {timeAgo(entry.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
|
{entry.status === 'active' && (
|
|
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
|
|
<TimerOff className="h-3.5 w-3.5" />
|
|
Expire
|
|
</Button>
|
|
)}
|
|
{confirming ? (
|
|
<>
|
|
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Confirm delete
|
|
</Button>
|
|
<Button onClick={() => setConfirming(false)} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export const KeysView = () => {
|
|
const { keys, isLoading, error } = useOffscaleKeys();
|
|
const { active } = useOffscaleServers();
|
|
const [creating, setCreating] = useState(false);
|
|
const [secret, setSecret] = useState<string | null>(null);
|
|
const [filter, setFilter] = useState<StatusFilter>('active');
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
|
|
const activeCount = keys.filter((k) => k.status === 'active').length;
|
|
|
|
return (
|
|
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
|
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-4 px-1 pb-1">
|
|
<div className="min-w-0">
|
|
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
|
|
<p className="mt-0.5 text-xs text-zinc-500">
|
|
{activeCount} active of {keys.length}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
|
|
{STATUS_FILTERS.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onClick={() => setFilter(option.id)}
|
|
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
|
|
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{!creating && (
|
|
<Button variant="primary" onClick={() => setCreating(true)}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
New key
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
|
|
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
|
|
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
|
|
|
{keys.length === 0 && !creating && (
|
|
<EmptyBody
|
|
icon={<KeyRound className="h-6 w-6" />}
|
|
title="No pre-auth keys"
|
|
hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you."
|
|
/>
|
|
)}
|
|
{keys.length > 0 && visible.length === 0 && (
|
|
<div className="py-10 text-center text-sm text-zinc-500">
|
|
No active keys. Switch to “All” to see spent and expired ones.
|
|
</div>
|
|
)}
|
|
|
|
{visible.map((entry) => (
|
|
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
|
|
))}
|
|
</div>
|
|
</ViewShell>
|
|
);
|
|
};
|