offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import { useState } from 'react';
|
||||
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
|
||||
import type { HeadscalePreAuthKey } from './shared';
|
||||
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
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 } = useHeadscaleUsers();
|
||||
const { create } = useHeadscaleKeys();
|
||||
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(headscaleErrorMessage(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: HeadscalePreAuthKey; onError: (message: string) => void };
|
||||
|
||||
const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
||||
const { expire, remove } = useHeadscaleKeys();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const busy = expire.isPending || remove.isPending;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(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 } = useHeadscaleKeys();
|
||||
const { active } = useHeadscaleServers();
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user