diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 56fef0f..6c0ac05 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -45,7 +45,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { : { name: principal.displayName, subject: "api_key" }; // MARK: The session should stay valid if Headscale isn't healthy - const isHealthy = await api.isHealthy(); + const isHealthy = await context.headscale.health(); if (isHealthy) { try { await api.apiKeys.list(); diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index af5a008..b86fbd2 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -33,6 +33,8 @@ export async function loginAction({ request, context }: Route.LoaderArgs) { }; } + // Build a client with the candidate API key the user just submitted, so the + // GET /api/v1/apikey call below validates the key against Headscale itself. const api = context.headscale.client(apiKey); try { const apiKeys = await api.apiKeys.list(); diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 1c2e986..7fa6471 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -56,6 +56,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { }); try { + // Looks up the Headscale user that matches this OIDC identity. We use + // the configured admin API key here — not a per-request one — because + // there is no per-request key yet (the session is being created). const hsApi = context.headscale.client(context.headscaleApiKey!); const hsUsers = await hsApi.users.list(); const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email); diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 089959e..4167d16 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -16,9 +16,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return data({ success: false }, 403); } - // We only need it for health checks which don't require auth - const api = context.headscale.client("fake-api-key"); - const formData = await request.formData(); const action = formData.get("action_id")?.toString(); if (!action) { @@ -39,7 +36,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Tailnet renamed successfully" }; } case "toggle_magic": { @@ -55,7 +52,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Magic DNS state updated successfully" }; } case "remove_ns": { @@ -88,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { ]); } - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Nameserver removed successfully" }; } case "add_ns": { @@ -123,7 +120,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { ]); } - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Nameserver added successfully" }; } case "remove_domain": { @@ -141,7 +138,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Domain removed successfully" }; } case "add_domain": { @@ -161,7 +158,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "Domain added successfully" }; } case "remove_record": { @@ -183,7 +180,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return; } - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "DNS record removed successfully" }; } case "add_record": { @@ -205,7 +202,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return; } - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "DNS record added successfully" }; } case "override_dns": { @@ -222,7 +219,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); - await context.integration?.onConfigChange(api); + await context.integration?.onConfigChange(context.headscale); return { message: "DNS override updated successfully" }; } default: diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 9ab4db6..9348855 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -28,8 +28,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - // We only need healthchecks which don't rely on an API key - const api = context.headscale.client("fake-api-key"); switch (action) { case "add_domain": { const domain = formData.get("domain")?.toString()?.trim(); @@ -48,7 +46,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("Domain added successfully."); } @@ -76,7 +74,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) value: domains, }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("Domain removed successfully."); } @@ -97,7 +95,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("Group added successfully."); } @@ -126,7 +124,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("Group removed successfully."); } @@ -147,7 +145,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("User added successfully."); } @@ -176,7 +174,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }, ]); - context.integration?.onConfigChange(api); + context.integration?.onConfigChange(context.headscale); return data("User removed successfully."); } diff --git a/app/routes/util/healthz.ts b/app/routes/util/healthz.ts index 017820e..48b456d 100644 --- a/app/routes/util/healthz.ts +++ b/app/routes/util/healthz.ts @@ -1,9 +1,7 @@ import type { Route } from "./+types/healthz"; export async function loader({ context }: Route.LoaderArgs) { - // Use a fake API key for healthcheck - const api = context.headscale.client("fake-api-key"); - const healthy = await api.isHealthy(); + const healthy = await context.headscale.health(); return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), { status: healthy ? 200 : 500, diff --git a/app/routes/util/info.ts b/app/routes/util/info.ts index 5af2038..2f64a4f 100644 --- a/app/routes/util/info.ts +++ b/app/routes/util/info.ts @@ -34,9 +34,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { ); } - // Use a fake API key for healthcheck - const api = context.headscale.client("fake-api-key"); - const healthy = await api.isHealthy(); + const healthy = await context.headscale.health(); const body = { status: healthy ? "healthy" : "unhealthy", diff --git a/app/server/config/integration/abstract.ts b/app/server/config/integration/abstract.ts index be6cc26..d758265 100644 --- a/app/server/config/integration/abstract.ts +++ b/app/server/config/integration/abstract.ts @@ -1,4 +1,4 @@ -import type { HeadscaleClient } from "~/server/headscale/api"; +import type { Headscale } from "~/server/headscale/api"; export abstract class Integration { protected context: NonNullable; @@ -11,6 +11,6 @@ export abstract class Integration { } abstract isAvailable(): Promise | boolean; - abstract onConfigChange(client: HeadscaleClient): Promise | void; + abstract onConfigChange(headscale: Headscale): Promise | void; abstract get name(): string; } diff --git a/app/server/config/integration/docker.ts b/app/server/config/integration/docker.ts index d176e39..c02cc0f 100644 --- a/app/server/config/integration/docker.ts +++ b/app/server/config/integration/docker.ts @@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises"; import { type } from "arktype"; import { Client } from "undici"; -import type { HeadscaleClient } from "~/server/headscale/api"; +import type { Headscale } from "~/server/headscale/api"; import log from "~/utils/log"; import { Integration } from "./abstract"; @@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration { const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options; @@ -96,7 +96,7 @@ export async function signalAndWaitHealthy( await setTimeout(retryDelayMs); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const healthy = await client.isHealthy(); + const healthy = await headscale.health(); if (healthy) { log.info("config", "Headscale is healthy after restart"); return true; diff --git a/app/server/config/integration/proc.ts b/app/server/config/integration/proc.ts index 506fd03..78cdb04 100644 --- a/app/server/config/integration/proc.ts +++ b/app/server/config/integration/proc.ts @@ -2,7 +2,7 @@ import { platform } from "node:os"; import { type } from "arktype"; -import type { HeadscaleClient } from "~/server/headscale/api"; +import type { Headscale } from "~/server/headscale/api"; import log from "~/utils/log"; import { Integration } from "./abstract"; @@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration; } export interface CreateHeadscaleOptions { url: string; certPath?: string; + /** + * How often to retry `/version` while Headscale is unreachable. + * Defaults to 30 seconds. Exposed for tests. + */ + retryIntervalMs?: number; } +const DEFAULT_RETRY_INTERVAL_MS = 30_000; + export async function createHeadscale(opts: CreateHeadscaleOptions): Promise { const transport = await createTransport({ url: opts.url, certPath: opts.certPath }); + const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS; - const versionInfo = await transport.getPublic<{ version: string }>("/version"); - const version = parseServerVersion(versionInfo.version); - const capabilities = capabilitiesFor(version); + let version: ServerVersion = parseServerVersion("unreachable"); + let capabilities: Capabilities = capabilitiesFor(version); + let detected = false; + let retryTimer: ReturnType | undefined; + let disposed = false; - if (version.unknown) { - log.warn( - "api", - "Could not parse Headscale version %s, assuming newest known capabilities", - versionInfo.version, - ); - } else { - log.info("api", "Connected to Headscale %s", formatServerVersion(version)); + async function detectOnce(): Promise { + try { + const { version: raw } = await transport.getPublic<{ version: string }>("/version"); + const parsed = parseServerVersion(raw); + version = parsed; + capabilities = capabilitiesFor(parsed); + detected = true; + if (parsed.unknown) { + log.warn( + "api", + "Could not parse Headscale version %s, assuming newest known capabilities", + raw, + ); + } else { + log.info("api", "Connected to Headscale %s", formatServerVersion(parsed)); + } + return true; + } catch (error) { + log.debug("api", "Headscale /version probe failed: %s", String(error)); + return false; + } } - return makeHeadscale(transport, version, capabilities); -} + function scheduleRetry() { + if (disposed || detected) return; + retryTimer = setTimeout(async () => { + retryTimer = undefined; + if (disposed) return; + if (await detectOnce()) return; + scheduleRetry(); + }, retryIntervalMs); + // Don't keep the event loop alive on this timer alone — Headplane + // should still shut down cleanly while we're waiting to retry. + retryTimer.unref?.(); + } + + if (!(await detectOnce())) { + log.warn( + "api", + "Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms", + retryIntervalMs, + ); + scheduleRetry(); + } -function makeHeadscale( - transport: Transport, - version: ServerVersion, - capabilities: Capabilities, -): Headscale { return { - version, - capabilities, + // Getters so callers always observe the latest detected values + // without having to know about the retry loop. + get version() { + return version; + }, + get capabilities() { + return capabilities; + }, health: () => transport.health(), client(apiKey) { return { @@ -84,9 +120,15 @@ function makeHeadscale( policy: makePolicyApi(transport, capabilities, apiKey), preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey), apiKeys: makeApiKeyApi(transport, capabilities, apiKey), - isHealthy: () => transport.health(), }; }, - dispose: () => transport.dispose(), + async dispose() { + disposed = true; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = undefined; + } + await transport.dispose(); + }, }; } diff --git a/runtime/http.ts b/runtime/http.ts index d96d7cb..ca79147 100644 --- a/runtime/http.ts +++ b/runtime/http.ts @@ -208,6 +208,7 @@ export function startHttpServer(opts: StartOptions): Server { } process.exit(0); }); + // Force exit if connections don't drain in time. setTimeout(() => process.exit(0), 5_000).unref(); }; diff --git a/tests/integration/api/versions.test.ts b/tests/integration/api/versions.test.ts index 20dc2de..1343a9c 100644 --- a/tests/integration/api/versions.test.ts +++ b/tests/integration/api/versions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { gte } from "~/server/headscale/api/server-version"; -import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env"; +import { getBootstrapClient, HS_VERSIONS, Version } from "../setup/env"; describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => { test("the runtime client is usable", async () => { @@ -34,8 +34,8 @@ describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => { }); test("the health check endpoint works", async () => { - const client = await getRuntimeClient(version); - const health = await client.isHealthy(); + const bootstrapper = await getBootstrapClient(version); + const health = await bootstrapper.health(); expect(health).toBe(true); }); }); diff --git a/tests/integration/platform/docker.test.ts b/tests/integration/platform/docker.test.ts index 62384d5..44d285f 100644 --- a/tests/integration/platform/docker.test.ts +++ b/tests/integration/platform/docker.test.ts @@ -81,8 +81,8 @@ describe("DockerIntegration", () => { socketPath: "/var/run/docker.sock", }); - const mockClient = { - isHealthy: async () => { + const mockHeadscale = { + health: async () => { try { const res = await dockerClient.request({ method: "GET", @@ -96,9 +96,9 @@ describe("DockerIntegration", () => { }, } as any; - await integration.onConfigChange(mockClient); + await integration.onConfigChange(mockHeadscale); - const healthy = await mockClient.isHealthy(); + const healthy = await mockHeadscale.health(); expect(healthy).toBe(true); }, 60_000); });