headscale: invites live under the companion's /api/v1 mount
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user