import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { dereference } from "@readme/openapi-parser"; import type { OpenAPIV2 } from "openapi-types"; import { data } from "react-router"; import { Agent, type Dispatcher, request } from "undici"; import log from "~/utils/log"; import endpointSets, { RuntimeApiClient } from "./endpoints"; import { undiciToFriendlyError } from "./error"; import { HeadscaleAPIError, isApiError } from "./error-client"; import { detectApiVersion, isAtLeast, type Version } from "./version"; /** * A low-level composed interface for interacting with the Headscale API. * This interface provides direct access to the underlying Undici agent * and methods for making API requests. * * It is also responsible for handling OpenAPI spec polling and hashing to * determine the implementations of API methods when requested for use. */ export interface HeadscaleApiInterface { /** * The underlying Undici agent used for making requests. */ undiciAgent: Agent; /** * The base URL of the Headscale API. */ baseUrl: string; /** * The OpenAPI hashes retrieved from the Headscale instance at runtime. * This is used to determine which implementations of API methods to use. */ openapiHashes: Record | null; /** * The detected API version of the connected Headscale instance. */ apiVersion: Version; /** * Retrieves a runtime API client for the given API key. * * @param apiKey The API key to use for authentication. * @returns A `RuntimeApiClient` instance for interacting with the API. */ getRuntimeClient(apiKey: string): RuntimeApiClient; /** * A set of helper methods made available to API method implementations. * The idea is to make interacting with the API easier by providing * common functionality that can be reused across multiple methods. */ clientHelpers: { /** * Checks if the connected Headscale instance's API version * is at least the specified version. * * @param version The version to check against. * @returns `true` if the API version is at least the specified version, `false` otherwise. */ isAtleast(version: Version): boolean; /** * Makes a raw fetch request to the Headscale API via the Undici agent. * This method is used internally by API method implementations * to make requests to the Headscale API. * * @param path The API path to request. * @param options Optional request options. * @returns A promise that resolves to the response data. */ rawFetch( path: string, options?: Partial, ): Promise; /** * Makes a typed API fetch request to the Headscale API. * This method is used internally by API method implementations * to make requests to the Headscale API and parse the response. * * @param method The HTTP method to use. * @param apiPath The API path to request. * @param apiKey The API key to use for authentication. * @param bodyOrQuery Optional body or query parameters. * @returns A promise that resolves to the typed response data. */ apiFetch( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", apiPath: `v1/${string}`, apiKey: string, bodyOrQuery?: Record, ): Promise; }; } /** * Creates a new Headscale API client interface. * * @param baseUrl The base URL of the Headscale API. * @param certPath Optional path to a custom TLS certificate for secure connections. * @returns A promise that resolves to a `HeadscaleApiClient` instance. */ export async function createHeadscaleInterface( baseUrl: string, certPath?: string, ): Promise { const undiciAgent = await createUndiciAgent(certPath); let openapiHashes: Record | null = null; let apiVersion: Version; const rawFetch = async ( url: string, options?: Partial, ): Promise => { const method = options?.method ?? "GET"; log.debug("api", "%s %s", method, url); try { const res = await request(new URL(url, baseUrl), { dispatcher: undiciAgent, headers: { ...options?.headers, Accept: "application/json", "User-Agent": `Headplane/${__VERSION__}`, }, body: options?.body, method, }); return res; } catch (error) { const errorBody = undiciToFriendlyError(error, `${method} ${url}`); throw data(errorBody, { status: 502, statusText: "Bad Gateway", }); } }; const apiFetch = async ( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", apiPath: `v1/${string}`, apiKey: string, bodyOrQuery?: Record, ): Promise => { let url = `/api/${apiPath}`; const options: Partial = { method: method, headers: { Authorization: `Bearer ${apiKey}`, }, }; if (bodyOrQuery) { if (method === "GET" || method === "DELETE") { const params = new URLSearchParams(); for (const [key, value] of Object.entries(bodyOrQuery)) { if (value !== undefined) { params.append(key, String(value)); } } if ([...params.keys()].length > 0) { url += `?${params.toString()}`; } } else { options.body = JSON.stringify(bodyOrQuery); options.headers = { ...options.headers, "Content-Type": "application/json", }; } } const res = await rawFetch(url, options); if (res.statusCode >= 400) { log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode); const rawData = await res.body.text(); const jsonData = (() => { try { return JSON.parse(rawData) as Record; } catch { return null; } })(); throw data( { requestUrl: `${method} ${apiPath}`, statusCode: res.statusCode, rawData, data: jsonData, } satisfies HeadscaleAPIError, { status: 502, statusText: "Bad Gateway" }, ); } return res.body.json() as Promise; }; /** * Polls the OpenAPI spec endpoint and generates operation hashes. * This is used to determine which implementations of API methods to use. * * @returns A promise that resolves to the OpenAPI operation hashes. */ async function fetchAndHashOpenapi(): Promise | null> { try { const res = await rawFetch("/swagger/v1/openapiv2.json"); if (res.statusCode !== 200) { log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode); return null; } const body = await res.body.json(); const spec = await dereference(body as OpenAPIV2.Document); const hashes = generateSpecHashes(spec); log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length); return hashes; } catch (error) { if (isApiError(error)) { log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode); } return null; } } const isAtleast = (version: Version): boolean => { return isAtLeast(apiVersion, version); }; openapiHashes = await fetchAndHashOpenapi(); apiVersion = detectApiVersion(openapiHashes); setInterval(async () => { const hashes = await fetchAndHashOpenapi(); if (hashes) { openapiHashes = hashes; apiVersion = detectApiVersion(openapiHashes); } }, 60_000); // every 60 seconds return { undiciAgent, baseUrl, openapiHashes, apiVersion, getRuntimeClient: (apiKey: string) => { return endpointSets( { rawFetch, apiFetch, isAtleast, }, apiKey, ); }, clientHelpers: { rawFetch, apiFetch, isAtleast, }, }; } /** * Creates a new Undici agent for making HTTP requests. * * @param certPath Optional path to a custom TLS certificate for secure connections. * @returns A promise that resolves to an `Agent` instance. */ async function createUndiciAgent(certPath?: string): Promise { if (!certPath) { return new Agent(); } try { log.debug("config", "Loading certificate from %s", certPath); const data = await readFile(certPath, "utf8"); log.info("config", "Using certificate from %s", certPath); return new Agent({ connect: { ca: data.trim() } }); } catch (error) { log.error("config", "Failed to load Headscale TLS cert: %s", error); log.debug("config", "Error Details: %o", error); return new Agent(); } } function generateSpecHashes(spec: OpenAPIV2.Document) { const hashes: Record = {}; const seen = new Set(); for (const [path, item] of Object.entries(spec.paths)) { for (const [method, operation] of Object.entries(item)) { if (typeof operation !== "object") { continue; } const { parameters, responses } = operation as OpenAPIV2.OperationObject; const raw = JSON.stringify( { path, method: method.toUpperCase(), parameters, responses, }, Object.keys({ path, method, parameters, responses }).sort(), ); const hash = createHash("md5").update(raw).digest("hex").slice(0, 16); const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; seen.add(final); hashes[`${method.toUpperCase()} ${path}`] = final; } } return hashes; }