Files
headplane/app/server/headscale/api/index.ts
T
2025-12-04 03:37:48 -05:00

341 lines
9.1 KiB
TypeScript

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<string, string> | 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<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* 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<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
}
/**
* 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<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
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 <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === 'GET' || method === 'DELETE') {
// Filter out undefined values
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<string, unknown>;
} 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<T>;
};
/**
* 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<Record<string, string> | 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) =>
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<Agent> {
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<string, string> = {};
const seen = new Set<string>();
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;
}