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,394 @@
|
||||
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';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Plain https now — the link lands on a page the companion serves, which bounces into the app. It goes in
|
||||
// `url` rather than `text` so share targets treat it as a link and preserve the fragment. A cancelled sheet
|
||||
// rejects — nothing to report there, the link is still on screen.
|
||||
const share = () => {
|
||||
void navigator
|
||||
.share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}`, url: invite.url })
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
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="Device name (optional)"
|
||||
value={note}
|
||||
onChange={setNote}
|
||||
placeholder="andre-iphone"
|
||||
hint="Prefilled on the phone's join screen and used as the node's name, which the person can edit. It labels this invite in your list too."
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user