From 3ca596860556f886155e565fff0c855e3671e206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 5 Aug 2026 18:49:41 +0000 Subject: [PATCH] headscale: invites live under the companion's /api/v1 mount --- src/probe-companion.ts | 11 +++++++++++ src/servers/sidecar/headscale/invites.ts | 13 ++++++++++--- .../officerdev/src/apps/Headscale/InvitesView.tsx | 14 ++++++-------- 3 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 src/probe-companion.ts diff --git a/src/probe-companion.ts b/src/probe-companion.ts new file mode 100644 index 00000000..da023edd --- /dev/null +++ b/src/probe-companion.ts @@ -0,0 +1,11 @@ +import { getActiveHeadscaleCredentials } from 'officerdb'; + +const creds = await getActiveHeadscaleCredentials(1); +if (!creds) { console.log('no active server'); process.exit(0); } +console.log('server:', creds.name, creds.url, 'key prefix:', creds.apiKey.slice(0, 9) + '…', 'len', creds.apiKey.length); +for (const path of ['/ping', '/health', '/api/v1/enroll/invites']) { + const res = await fetch(`${creds.url}/officer-api${path}`, { + headers: { authorization: `Bearer ${creds.apiKey}`, accept: 'application/json' }, + }); + console.log(path, res.status, (await res.text()).slice(0, 200)); +} diff --git a/src/servers/sidecar/headscale/invites.ts b/src/servers/sidecar/headscale/invites.ts index 9f27199b..34e44acf 100644 --- a/src/servers/sidecar/headscale/invites.ts +++ b/src/servers/sidecar/headscale/invites.ts @@ -21,6 +21,13 @@ import { activeCreds, callCompanion, readBody, unavailable } from './companion'; // companion route: most registered servers have no companion at all, and that is a state to render rather // than a request that failed. +/** + * Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is + * `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container + * routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift. + */ +const INVITES_PATH = '/api/v1/enroll/invites'; + /** 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; @@ -93,19 +100,19 @@ async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): P 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 }); + const res = await callCompanion(creds, { path: INVITES_PATH, 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 { - const res = await callCompanion(creds, { path: '/enroll/invites' }); + const res = await callCompanion(creds, { path: INVITES_PATH }); 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 { - const res = await callCompanion(creds, { path: `/enroll/invites/${encodeURIComponent(id)}`, method: 'DELETE' }); + const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' }); return relay(res, (body) => ({ available: true, ...body })); } diff --git a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx index de8f0507..1bdf9e9a 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx @@ -80,16 +80,14 @@ type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => v const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { const [showQr, setShowQr] = useState(true); + // The link goes in `text`, not `url`: it is a custom scheme (`officer-offscale://join#…`) and several + // share-sheet implementations only accept http(s) in the url field, rejecting the whole call. As text it + // is passed through verbatim by every messenger. A cancelled sheet rejects too — nothing to report there, + // the link is still on screen. 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. - }); + .share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}: ${invite.url}` }) + .catch(() => {}); }; return (