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
+12 -7
View File
@@ -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
* 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;
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
* itself could not be reached — the caller decides how to present that, because for this feature
* "unreachable" is information rather than a failure.
*/
async function callCompanion(
export async function callCompanion(
creds: HeadscaleServerCredentials,
{ path, method = 'GET', timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
): Promise<Response | string> {
let res: Response;
try {
res = await fetch(`${creds.url}/officer-api${path}`, {
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),
});
} catch (err) {
@@ -67,7 +72,7 @@ async function callCompanion(
}
/** 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(() => '');
if (!text) return 'the companion returned an empty body';
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. */
async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });