headscale: device invites — admin surface for offscale enrollment

This commit is contained in:
2026-08-05 18:10:44 +00:00
parent 55abaa4042
commit 4cee0335c2
9 changed files with 673 additions and 8 deletions
@@ -1,6 +1,17 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Network, Server, Laptop, Users, KeyRound, ShieldCheck, Activity, TerminalSquare, Check } from 'lucide-react';
import {
Network,
Server,
Laptop,
Users,
KeyRound,
Smartphone,
ShieldCheck,
Activity,
TerminalSquare,
Check,
} from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
@@ -18,6 +29,7 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
nodes: Laptop,
users: Users,
keys: KeyRound,
invites: Smartphone,
policy: ShieldCheck,
diagnostics: Activity,
console: TerminalSquare,
@@ -3,6 +3,7 @@ import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
import { InvitesView } from './InvitesView';
import { PolicyView } from './PolicyView';
import { DiagnosticsView } from './DiagnosticsView';
import { ConsoleView } from './ConsoleView';
@@ -22,6 +23,8 @@ export const HeadscaleView = () => {
return <UsersView />;
case 'keys':
return <KeysView />;
case 'invites':
return <InvitesView />;
case 'policy':
return <PolicyView />;
case 'diagnostics':
@@ -0,0 +1,396 @@
import { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
import { useHeadscaleInvites } from './useHeadscaleInvites';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { fullDate, timeAgo, timeUntil } from './format';
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
import { EmptyBody, ViewShell } from './ViewShell';
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
//
// The point of the feature is that the person joining does nothing but tap a link and press Join: no app
// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin
// does all of it here and sends one link.
//
// TWO RULES SHAPE THIS FILE.
//
// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display,
// log or store it beyond the moment it is handed to the admin. So the created invite lives in component
// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and
// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another.
// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted
// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole
// reason the credential is not in the URL.
const STATUS_TONE: Record<InviteStatus, 'ok' | 'warn' | 'bad' | 'idle'> = {
pending: 'warn',
claimed: 'ok',
expired: 'idle',
revoked: 'bad',
};
/** Presets rather than a free number: every one is inside the spec's 60s24h range by construction. */
const TTL_OPTIONS = [
{ seconds: 300, label: '5 minutes' },
{ seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' },
{ seconds: 3600, label: '1 hour' },
{ seconds: 86_400, label: '24 hours' },
] as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(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>
);
};
/**
* The QR, rendered client-side into a canvas.
*
* It never leaves the browser — an image endpoint would put the claim token in a request line and therefore
* in a server log, which is the exact thing the fragment-only link format exists to prevent. Error
* correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not
* printed and posted.
*/
const InviteQr = ({ url }: { url: string }) => {
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvas.current) return;
void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 });
}, [url]);
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
};
type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void };
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
const [showQr, setShowQr] = useState(true);
const share = () => {
void navigator
.share?.({
title: 'Join the tailnet',
text: `Tap to join ${invite.user}'s network`,
url: invite.url,
})
.catch(() => {
// A cancelled share sheet rejects. Nothing to report — the link is still on screen.
});
};
return (
<div className="overflow-hidden rounded-xl border border-primary/30 bg-primary/[0.06]">
<div className="flex items-start gap-2.5 border-b border-primary/20 px-4 py-3">
<Smartphone className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-100">Send this link to the device</div>
<p className="mt-0.5 text-xs leading-snug text-zinc-400">
It opens OffScale, shows one confirmation screen and joins as{' '}
<span className="text-zinc-200">{invite.user}</span>. Single use, and it stops working{' '}
{timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy dismiss this and it is gone.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-300">
{invite.url}
</div>
{showQr && (
<div className="flex justify-center py-1">
<InviteQr url={invite.url} />
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={invite.url} label="Copy link" />
{/* Only where the OS actually has a share sheet — a button that silently does nothing is worse
than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */}
{typeof navigator.share === 'function' && (
<Button onClick={share}>
<Share2 className="h-3.5 w-3.5" />
Share
</Button>
)}
<Button onClick={() => setShowQr((v) => !v)}>
<QrCode className="h-3.5 w-3.5" />
{showQr ? 'Hide QR' : 'Show QR'}
</Button>
<Button variant="danger" onClick={onDismiss}>
Done
</Button>
</div>
</div>
</div>
);
};
type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void };
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleInvites();
const [user, setUser] = useState('');
const [note, setNote] = useState('');
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
const [ephemeral, setEphemeral] = useState(false);
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
// The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the
// one this list came from by the time it is claimed.
const chosen = user || users[0]?.name;
if (!chosen) return setError('Create a user first — an invite files the joining device under one.');
try {
const invite = await create.mutateAsync({
user: chosen,
ttlSeconds: ttl,
ephemeral,
note: note.trim(),
tags: tags
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean),
});
onCreated(invite);
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">Authorize a new device</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={user || users[0]?.name || ''}
onChange={(ev) => setUser(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((entry) => (
<option key={entry.id} value={entry.name}>
{entry.name}
</option>
))}
</select>
</label>
<Field
label="Note (optional)"
value={note}
onChange={setNote}
placeholder="Andre's wife iPhone"
hint="Only for your own list — it is never shown to whoever receives the link."
/>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">Link valid for</span>
<select
value={ttl}
onChange={(ev) => setTtl(Number(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"
>
{TTL_OPTIONS.map((option) => (
<option key={option.seconds} value={option.seconds}>
{option.label}
</option>
))}
</select>
<span className="text-[11px] leading-snug text-zinc-600">
How long the link can be claimed for. Short is safer you can always mint another.
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={ephemeral}
onChange={(ev) => setEphemeral(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">Ephemeral</span>
<span className="block text-[11px] leading-snug text-zinc-600">
The node is removed when it goes offline. Wrong for a phone; right for a container.
</span>
</span>
</label>
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="phone, family"
hint="Comma or space separated. The tag: prefix is added for you, and the device cannot change them."
/>
{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 invite
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void };
const InviteRow = ({ invite, onError }: InviteRowProps) => {
const { revoke } = useHeadscaleInvites();
const [confirming, setConfirming] = useState(false);
const run = async () => {
try {
await revoke.mutateAsync(invite.id);
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[invite.status] ?? 'idle'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm text-zinc-200">{invite.note || 'Untitled invite'}</span>
<Badge>{invite.user}</Badge>
{invite.ephemeral && <Badge>ephemeral</Badge>}
{invite.tags?.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>{invite.status}</span>
{invite.status === 'pending' && (
<span title={fullDate(invite.expiresAt ?? null)}>· expires {timeUntil(invite.expiresAt ?? null)}</span>
)}
{invite.status === 'claimed' && (
<span title={fullDate(invite.claimedAt ?? null)}>
· claimed {timeAgo(invite.claimedAt ?? null)}
{invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''}
</span>
)}
<span>· created {timeAgo(invite.createdAt ?? null)}</span>
</div>
</div>
{/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in
Nodes, and conflating the two here would make "revoke" mean two different things. */}
{invite.status === 'pending' && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{confirming ? (
<>
<Button variant="danger" onClick={() => void run()} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Confirm revoke
</Button>
<Button onClick={() => setConfirming(false)} disabled={revoke.isPending}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
)}
</div>
)}
</div>
</Card>
);
};
export const InvitesView = () => {
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const pending = invites.filter((i) => i.status === 'pending').length;
return (
<ViewShell isLoading={isLoading} error={error} label="device invites">
<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">Device invites</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`}
</p>
</div>
{!creating && !unavailable && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
Authorize new device
</Button>
)}
</div>
{/* Most registered servers have no enrolment API, and that is a normal state — the rest of the
Headscale sections work regardless, so this must not read as a broken screen. */}
{unavailable && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="This server cannot mint invites"
hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`}
/>
)}
{created && <InviteLinkPanel invite={created} onDismiss={() => setCreated(null)} />}
{creating && <CreateInviteForm onCreated={setCreated} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{!unavailable && invites.length === 0 && !creating && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="No invites yet"
hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure."
/>
)}
{invites.map((invite) => (
<InviteRow key={invite.id} invite={invite} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -9,6 +9,7 @@ export const HEADSCALE_SECTIONS = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'invites', label: 'Device invites' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
@@ -179,3 +180,46 @@ export type HeadscalePreAuthKey = {
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.
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. */
export type HeadscaleInvite = {
id: string;
user: string;
note?: string | null;
status: InviteStatus;
ephemeral?: boolean;
tags?: string[];
createdAt?: string | null;
expiresAt?: string | null;
claimedAt?: string | null;
claimedFromIp?: string | null;
};
/**
* 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.
*/
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note: string;
};
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
/** 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_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;
@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
// 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
// not here.
//
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
// drops it. The list is refetched instead, which returns the same invite without its token.
const BASE = '/headscale/_officer/enroll/invites';
const INVITES_KEY = ['headscale', 'invites'] as const;
const EMPTY: InvitesListResult = { available: true, invites: [] };
export function useHeadscaleInvites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
const query = useQuery({
queryKey: INVITES_KEY,
queryFn: () => get<InvitesListResult>(BASE),
// A pending invite expires on a clock, so a list left open goes wrong on its own. Cheap: one companion
// call against a table with a handful of rows.
refetchInterval: 30_000,
staleTime: 10_000,
});
const create = useMutation({
mutationFn: async (input: InviteCreateInput): Promise<HeadscaleInviteCreated> => {
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
// form shows it where the admin is looking rather than rendering an empty link panel.
if (!result.available) throw new Error(result.reason);
return result.invite;
},
onSuccess: invalidate,
});
const revoke = useMutation({
mutationFn: (id: string) => del(`${BASE}/${encodeURIComponent(id)}`),
onSuccess: invalidate,
});
const result = query.data ?? EMPTY;
return {
invites: result.available ? result.invites : [],
/** Set when this server has no enrolment API — a state to explain, not an error. */
unavailable: result.available ? null : result.reason,
isLoading: query.isLoading,
error: query.error,
create,
revoke,
};
}