headscale: device invites — admin surface for offscale enrollment
This commit is contained in:
@@ -119,6 +119,18 @@ day. Kept here for what the policy work turned up, which is not obvious from the
|
|||||||
|
|
||||||
- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
|
- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
|
||||||
|
|
||||||
|
- [x] **Device invites, platform side** (`COMMS/OFFSCALE_INVITE_ENROLLMENT.md` §5, built 2026-08-05).
|
||||||
|
Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke —
|
||||||
|
`/_officer/enroll/invites` in `invites.ts`, `InvitesView`. **The records are not Officer's.** They
|
||||||
|
proxy to the server's Officer Companion, because the joining phone has to claim without an Officer
|
||||||
|
account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when
|
||||||
|
the platform is down" argument says the same. Officer stores no invite and no claim token.
|
||||||
|
**Waiting on the companion:** `/officer-api/enroll/*` is not deployed anywhere yet, so the section
|
||||||
|
renders its unavailable state. Four gaps were sent back to the spec author: `keys.ts` has no
|
||||||
|
sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable
|
||||||
|
behind nginx, `tailnet` is not a headscale concept and has to be recorded on the invite, and
|
||||||
|
`/api/v1/enroll/*` collides with headscale's own namespace.
|
||||||
|
|
||||||
## Known bugs
|
## Known bugs
|
||||||
|
|
||||||
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
||||||
|
|||||||
@@ -26,26 +26,31 @@ import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './r
|
|||||||
* companion is a state to render, not a request that failed — the admin API on the same domain is
|
* companion is a state to render, not a request that failed — the admin API on the same domain is
|
||||||
* independent and may still be working, so this must not surface as an error the UI swallows.
|
* independent and may still be working, so this must not surface as an error the UI swallows.
|
||||||
*/
|
*/
|
||||||
const unavailable = (reason: string) => ({ available: false as const, reason });
|
export const unavailable = (reason: string) => ({ available: false as const, reason });
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
type CompanionCall = { path: string; method?: string; timeoutMs?: number; signal?: AbortSignal };
|
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
|
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
|
||||||
* itself could not be reached — the caller decides how to present that, because for this feature
|
* itself could not be reached — the caller decides how to present that, because for this feature
|
||||||
* "unreachable" is information rather than a failure.
|
* "unreachable" is information rather than a failure.
|
||||||
*/
|
*/
|
||||||
async function callCompanion(
|
export async function callCompanion(
|
||||||
creds: HeadscaleServerCredentials,
|
creds: HeadscaleServerCredentials,
|
||||||
{ path, method = 'GET', 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;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${creds.url}/officer-api${path}`, {
|
res = await fetch(`${creds.url}/officer-api${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { authorization: `Bearer ${creds.apiKey}`, accept: 'application/json' },
|
headers: {
|
||||||
|
authorization: `Bearer ${creds.apiKey}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -67,7 +72,7 @@ async function callCompanion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
|
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
|
||||||
async function readBody(res: Response): Promise<Record<string, unknown> | string> {
|
export async function readBody(res: Response): Promise<Record<string, unknown> | string> {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
if (!text) return 'the companion returned an empty body';
|
if (!text) return 'the companion returned an empty body';
|
||||||
try {
|
try {
|
||||||
@@ -80,7 +85,7 @@ async function readBody(res: Response): Promise<Record<string, unknown> | string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
||||||
const creds = await getActiveHeadscaleCredentials(userId);
|
const creds = await getActiveHeadscaleCredentials(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 });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getActiveHeadscaleCredentials } from 'officerdb';
|
|||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
|
import { handleInvitesRoute } from './invites';
|
||||||
|
|
||||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||||
// 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.
|
||||||
@@ -64,6 +65,11 @@ async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise<Response | null> {
|
export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise<Response | null> {
|
||||||
|
// `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device
|
||||||
|
// is not here and there is no Officer session on it. Same prefix because it is the same feature to the
|
||||||
|
// person using it, and because the spec names it that way.
|
||||||
|
if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1));
|
||||||
|
|
||||||
if (segments.length > 0) return null;
|
if (segments.length > 0) return null;
|
||||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||||
|
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
|
// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
|
||||||
|
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
|
||||||
|
// key it never had to be told.
|
||||||
|
//
|
||||||
|
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
|
||||||
|
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
|
||||||
|
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
|
||||||
|
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
|
||||||
|
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
|
||||||
|
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
|
||||||
|
//
|
||||||
|
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
|
||||||
|
// the moment it is handed to the admin." The create response passes through this process once, in memory,
|
||||||
|
// on its way to the browser — that is the whole of its life here.
|
||||||
|
//
|
||||||
|
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
|
||||||
|
// companion route: most registered servers have no companion at all, and that is a state to render rather
|
||||||
|
// than a request that failed.
|
||||||
|
|
||||||
|
/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
|
||||||
|
const DEFAULT_TTL_SECONDS = 900;
|
||||||
|
const MIN_TTL_SECONDS = 60;
|
||||||
|
const MAX_TTL_SECONDS = 86_400;
|
||||||
|
|
||||||
|
type CreateInput = {
|
||||||
|
user: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
ephemeral: boolean;
|
||||||
|
tags: string[];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
|
||||||
|
function parseCreate(body: Record<string, unknown> | null): CreateInput | Response {
|
||||||
|
const user = typeof body?.user === 'string' ? body.user.trim() : '';
|
||||||
|
if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user');
|
||||||
|
|
||||||
|
const raw = body?.ttlSeconds;
|
||||||
|
const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw);
|
||||||
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) {
|
||||||
|
return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and
|
||||||
|
// adding it here means the admin can type either form without minting a key that silently has no tag.
|
||||||
|
const tags = Array.isArray(body?.tags)
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
body.tags
|
||||||
|
.filter((t): t is string => typeof t === 'string')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : '';
|
||||||
|
|
||||||
|
return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a companion answer into ours.
|
||||||
|
*
|
||||||
|
* The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false`
|
||||||
|
* (render an explanation), a companion refusal keeps its own status and message (the admin typed something
|
||||||
|
* the server rejected), and success is the body with `available: true` on it.
|
||||||
|
*/
|
||||||
|
async function relay(res: Response | string, wrap: (body: Record<string, unknown>) => unknown): Promise<Response> {
|
||||||
|
if (typeof res === 'string') return Response.json(unavailable(res));
|
||||||
|
|
||||||
|
const body = await readBody(res);
|
||||||
|
if (typeof body === 'string') return Response.json(unavailable(body));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`;
|
||||||
|
return Response.json(
|
||||||
|
{ error, code: typeof body.code === 'string' ? body.code : undefined },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(wrap(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. */
|
||||||
|
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||||
|
const input = parseCreate(await readJson(ctx.req));
|
||||||
|
if (input instanceof Response) return input;
|
||||||
|
|
||||||
|
const res = await callCompanion(creds, { path: '/enroll/invites', method: 'POST', body: input });
|
||||||
|
return relay(res, (body) => ({ available: true, invite: body.invite ?? body }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
|
||||||
|
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
|
||||||
|
const res = await callCompanion(creds, { path: '/enroll/invites' });
|
||||||
|
return relay(res, (body) => ({ available: true, invites: Array.isArray(body.invites) ? body.invites : [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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> {
|
||||||
|
const res = await callCompanion(creds, { path: `/enroll/invites/${encodeURIComponent(id)}`, method: 'DELETE' });
|
||||||
|
return relay(res, (body) => ({ available: true, ...body }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */
|
||||||
|
export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
||||||
|
const creds = await activeCreds(ctx.userId);
|
||||||
|
if (creds instanceof Response) return creds;
|
||||||
|
|
||||||
|
const [id, extra] = rest;
|
||||||
|
if (extra) return notFound();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
if (ctx.req.method === 'POST') return create(creds, ctx);
|
||||||
|
if (ctx.req.method === 'GET') return list(creds);
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.req.method !== 'DELETE') return methodNotAllowed();
|
||||||
|
return revoke(creds, id);
|
||||||
|
}
|
||||||
@@ -1,6 +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 { 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 { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||||
|
|
||||||
@@ -18,6 +29,7 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
|||||||
nodes: Laptop,
|
nodes: Laptop,
|
||||||
users: Users,
|
users: Users,
|
||||||
keys: KeyRound,
|
keys: KeyRound,
|
||||||
|
invites: Smartphone,
|
||||||
policy: ShieldCheck,
|
policy: ShieldCheck,
|
||||||
diagnostics: Activity,
|
diagnostics: Activity,
|
||||||
console: TerminalSquare,
|
console: TerminalSquare,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ServersView } from './ServersView';
|
|||||||
import { NodesView } from './NodesView';
|
import { NodesView } from './NodesView';
|
||||||
import { UsersView } from './UsersView';
|
import { UsersView } from './UsersView';
|
||||||
import { KeysView } from './KeysView';
|
import { KeysView } from './KeysView';
|
||||||
|
import { InvitesView } from './InvitesView';
|
||||||
import { PolicyView } from './PolicyView';
|
import { PolicyView } from './PolicyView';
|
||||||
import { DiagnosticsView } from './DiagnosticsView';
|
import { DiagnosticsView } from './DiagnosticsView';
|
||||||
import { ConsoleView } from './ConsoleView';
|
import { ConsoleView } from './ConsoleView';
|
||||||
@@ -22,6 +23,8 @@ export const HeadscaleView = () => {
|
|||||||
return <UsersView />;
|
return <UsersView />;
|
||||||
case 'keys':
|
case 'keys':
|
||||||
return <KeysView />;
|
return <KeysView />;
|
||||||
|
case 'invites':
|
||||||
|
return <InvitesView />;
|
||||||
case 'policy':
|
case 'policy':
|
||||||
return <PolicyView />;
|
return <PolicyView />;
|
||||||
case 'diagnostics':
|
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 60s–24h 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: 'nodes', label: 'Nodes' },
|
||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
{ id: 'keys', label: 'Pre-auth keys' },
|
{ id: 'keys', label: 'Pre-auth keys' },
|
||||||
|
{ id: 'invites', label: 'Device invites' },
|
||||||
{ id: 'policy', label: 'Access policy' },
|
{ id: 'policy', label: 'Access policy' },
|
||||||
{ id: 'diagnostics', label: 'Diagnostics' },
|
{ id: 'diagnostics', label: 'Diagnostics' },
|
||||||
{ id: 'console', label: 'Console' },
|
{ 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. */
|
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
|
||||||
export const NO_ACTIVE_SERVER = 'no_active_server';
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user