chore: format everything with oxfmt

This commit is contained in:
Aarnav Tale
2026-04-26 20:33:21 -04:00
parent b961b339bb
commit 5a2098eea5
49 changed files with 1584 additions and 1696 deletions
+4
View File
@@ -1,4 +1,5 @@
# Headplane # Headplane
> A feature-complete web UI for [Headscale](https://headscale.net) > A feature-complete web UI for [Headscale](https://headscale.net)
<picture> <picture>
@@ -32,14 +33,17 @@ These are some of the features that Headplane offers:
- Configurability for Headscale's settings - Configurability for Headscale's settings
## Deployment ## Deployment
Refer to the [website](https://headplane.net) for detailed installation instructions. Refer to the [website](https://headplane.net) for detailed installation instructions.
## Versioning ## Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0). Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing. PR is opened and actively in testing.
## Contributing ## Contributing
Headplane is an open-source project and contributions are welcome! If you have Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info. refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
+9 -9
View File
@@ -1,12 +1,12 @@
import { StrictMode, startTransition } from 'react'; import { StrictMode, startTransition } from "react";
import { hydrateRoot } from 'react-dom/client'; import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from 'react-router/dom'; import { HydratedRouter } from "react-router/dom";
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,
<StrictMode> <StrictMode>
<HydratedRouter /> <HydratedRouter />
</StrictMode>, </StrictMode>,
); );
}); });
+3 -3
View File
@@ -1,10 +1,10 @@
import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node"; import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot"; import { isbot } from "isbot";
import { PassThrough } from "node:stream"; import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router"; import { ServerRouter } from "react-router";
export const streamTimeout = 5_000; export const streamTimeout = 5_000;
+10 -10
View File
@@ -1,14 +1,14 @@
import type { Route } from './+types/healthz'; import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
// Use a fake API key for healthcheck // Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key'); const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy(); const healthy = await api.isHealthy();
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), { return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500, status: healthy ? 200 : 500,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
} }
+53 -51
View File
@@ -1,59 +1,61 @@
import { versions } from 'node:process'; import { versions } from "node:process";
import { data } from 'react-router';
import type { Route } from './+types/info'; import { data } from "react-router";
import type { Route } from "./+types/info";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) { if (context.config.server.info_secret == null) {
throw data( throw data(
{ {
status: 'Forbidden', status: "Forbidden",
}, },
403, 403,
); );
} }
const bearer = request.headers.get('Authorization') ?? ''; const bearer = request.headers.get("Authorization") ?? "";
if (!bearer.startsWith('Bearer ')) { if (!bearer.startsWith("Bearer ")) {
throw data( throw data(
{ {
status: 'Unauthorized', status: "Unauthorized",
}, },
401, 401,
); );
} }
const token = bearer.slice('Bearer '.length).trim(); const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) { if (token !== context.config.server.info_secret) {
throw data( throw data(
{ {
status: 'Forbidden', status: "Forbidden",
}, },
403, 403,
); );
} }
// Use a fake API key for healthcheck // Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key'); const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy(); const healthy = await api.isHealthy();
const body = { const body = {
status: healthy ? 'healthy' : 'unhealthy', status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__, headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown', headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
internal_versions: { internal_versions: {
node: versions.node, node: versions.node,
v8: versions.v8, v8: versions.v8,
uv: versions.uv, uv: versions.uv,
zlib: versions.zlib, zlib: versions.zlib,
openssl: versions.openssl, openssl: versions.openssl,
libc: versions.libc, libc: versions.libc,
}, },
}; };
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'react-router'; import { redirect } from "react-router";
export async function loader() { export async function loader() {
return redirect('/machines'); return redirect("/machines");
} }
+54 -56
View File
@@ -1,74 +1,72 @@
interface ErrorCodes { interface ErrorCodes {
CONFLICTING_SECRET_PATH_FIELD: { CONFLICTING_SECRET_PATH_FIELD: {
fieldName: string; fieldName: string;
}; };
INVALID_REQUIRED_FIELDS: { INVALID_REQUIRED_FIELDS: {
messages: string[]; messages: string[];
}; };
MISSING_INTERPOLATION_VARIABLE: { MISSING_INTERPOLATION_VARIABLE: {
pathKey: string; pathKey: string;
variableName: string; variableName: string;
}; };
MISSING_SECRET_FILE: { MISSING_SECRET_FILE: {
pathKey: string; pathKey: string;
filePath: string; filePath: string;
}; };
} }
const translationsWithVars: { const translationsWithVars: {
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string; [K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
} = { } = {
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) => CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`, `Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
INVALID_REQUIRED_FIELDS: ({ messages }) => INVALID_REQUIRED_FIELDS: ({ messages }) =>
`The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`, `The configuration is missing required fields or has invalid values:\n- ${messages.join("\n- ")}`,
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) => MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`, `Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
MISSING_SECRET_FILE: ({ pathKey, filePath }) => MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`, `The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
} as const; } as const;
/** /**
* Custom error class for configuration-related errors. * Custom error class for configuration-related errors.
*/ */
export class ConfigError extends Error { export class ConfigError extends Error {
/** /**
* The error code representing the type of configuration error. * The error code representing the type of configuration error.
*/ */
code: keyof ErrorCodes; code: keyof ErrorCodes;
/** /**
* Creates a new ConfigError instance. * Creates a new ConfigError instance.
* *
* @param code The error code * @param code The error code
* @param vars The variables to interpolate into the error message * @param vars The variables to interpolate into the error message
*/ */
constructor(code: keyof ErrorCodes, vars: unknown) { constructor(code: keyof ErrorCodes, vars: unknown) {
super( super(
translationsWithVars[code]( translationsWithVars[code](
vars as (typeof translationsWithVars)[typeof code] extends ( vars as (typeof translationsWithVars)[typeof code] extends (vars: infer U) => string
vars: infer U, ? U
) => string : never,
? U ),
: never, );
), this.code = code;
); this.name = "ConfigError";
this.code = code; }
this.name = 'ConfigError';
}
/** /**
* Factory method to create a ConfigError instance. * Factory method to create a ConfigError instance.
* *
* @param code The error code * @param code The error code
* @param vars The variables to interpolate into the error message * @param vars The variables to interpolate into the error message
* @returns A new ConfigError instance * @returns A new ConfigError instance
*/ */
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) { static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
return new ConfigError(code, vars); return new ConfigError(code, vars);
} }
} }
+11 -11
View File
@@ -1,16 +1,16 @@
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints'; import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
export abstract class Integration<T> { export abstract class Integration<T> {
protected context: NonNullable<T>; protected context: NonNullable<T>;
constructor(context: T) { constructor(context: T) {
if (!context) { if (!context) {
throw new Error('Missing integration context'); throw new Error("Missing integration context");
} }
this.context = context; this.context = context;
} }
abstract isAvailable(): Promise<boolean> | boolean; abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void; abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string; abstract get name(): string;
} }
+47 -51
View File
@@ -1,62 +1,58 @@
import log from '~/utils/log'; import log from "~/utils/log";
import type { HeadplaneConfig } from '../config-schema';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export async function loadIntegration(context: HeadplaneConfig['integration']) { import type { HeadplaneConfig } from "../config-schema";
const integration = getIntegration(context); import dockerIntegration from "./docker";
if (!integration) { import kubernetesIntegration from "./kubernetes";
return; import procIntegration from "./proc";
}
try { export async function loadIntegration(context: HeadplaneConfig["integration"]) {
const res = await integration.isAvailable(); const integration = getIntegration(context);
if (!res) { if (!integration) {
log.error('config', 'Integration %s is not available', integration.name); return;
return; }
}
} catch (error) {
log.error(
'config',
'Failed to load integration %s: %s',
integration,
error,
);
log.debug('config', 'Loading error: %o', error);
return;
}
return integration; try {
const res = await integration.isAvailable();
if (!res) {
log.error("config", "Integration %s is not available", integration.name);
return;
}
} catch (error) {
log.error("config", "Failed to load integration %s: %s", integration, error);
log.debug("config", "Loading error: %o", error);
return;
}
return integration;
} }
function getIntegration(integration: HeadplaneConfig['integration']) { function getIntegration(integration: HeadplaneConfig["integration"]) {
const docker = integration?.docker; const docker = integration?.docker;
const k8s = integration?.kubernetes; const k8s = integration?.kubernetes;
const proc = integration?.proc; const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) { if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('config', 'No integrations enabled'); log.debug("config", "No integrations enabled");
return; return;
} }
if (docker?.enabled && k8s?.enabled && proc?.enabled) { if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('config', 'Multiple integrations enabled, please pick one only'); log.error("config", "Multiple integrations enabled, please pick one only");
return; return;
} }
if (docker?.enabled) { if (docker?.enabled) {
log.info('config', 'Using Docker integration'); log.info("config", "Using Docker integration");
return new dockerIntegration(integration!.docker!); return new dockerIntegration(integration!.docker!);
} }
if (k8s?.enabled) { if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration'); log.info("config", "Using Kubernetes integration");
return new kubernetesIntegration(integration!.kubernetes!); return new kubernetesIntegration(integration!.kubernetes!);
} }
if (proc?.enabled) { if (proc?.enabled) {
log.info('config', 'Using Proc integration'); log.info("config", "Using Proc integration");
return new procIntegration(integration!.proc!); return new procIntegration(integration!.proc!);
} }
} }
+3 -3
View File
@@ -1,11 +1,11 @@
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import { readdir, readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { platform } from "node:os"; import { platform } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log"; import log from "~/utils/log";
import { Integration } from "./abstract"; import { Integration } from "./abstract";
@@ -4,7 +4,6 @@ import { kill } from "node:process";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log"; import log from "~/utils/log";
/** /**
+2 -2
View File
@@ -1,8 +1,8 @@
import { type } from "arktype";
import { platform } from "node:os"; import { platform } from "node:os";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log"; import log from "~/utils/log";
import { Integration } from "./abstract"; import { Integration } from "./abstract";
+149 -154
View File
@@ -1,14 +1,17 @@
import { access, constants, readFile } from 'node:fs/promises'; import { access, constants, readFile } from "node:fs/promises";
import { type } from 'arktype';
import { load } from 'js-yaml'; import { type } from "arktype";
import log from '~/utils/log'; import { load } from "js-yaml";
import log from "~/utils/log";
import { import {
headplaneConfig, headplaneConfig,
PartialHeadplaneConfig, PartialHeadplaneConfig,
partialHeadplaneConfig, partialHeadplaneConfig,
pathSupportedKeys, pathSupportedKeys,
} from './config-schema'; } from "./config-schema";
import { ConfigError } from './error'; import { ConfigError } from "./error";
/** /**
* Main entrypoint that attempts to load and merge configuration from both * Main entrypoint that attempts to load and merge configuration from both
@@ -25,27 +28,27 @@ import { ConfigError } from './error';
* @throws {Error} If there are validation errors in the final configuration * @throws {Error} If there are validation errors in the final configuration
*/ */
export async function loadConfig(configPathOverride?: string) { export async function loadConfig(configPathOverride?: string) {
const configPath = const configPath =
configPathOverride != null configPathOverride != null
? configPathOverride ? configPathOverride
: process.env.HEADPLANE_CONFIG_PATH != null : process.env.HEADPLANE_CONFIG_PATH != null
? String(process.env.HEADPLANE_CONFIG_PATH) ? String(process.env.HEADPLANE_CONFIG_PATH)
: '/etc/headplane/config.yaml'; : "/etc/headplane/config.yaml";
const fileConfig = await loadConfigFile(configPath); const fileConfig = await loadConfigFile(configPath);
const envConfig = await loadConfigEnv(); const envConfig = await loadConfigEnv();
const combinedConfig = deepMerge(fileConfig, envConfig); const combinedConfig = deepMerge(fileConfig, envConfig);
await loadConfigKeyPaths(combinedConfig); await loadConfigKeyPaths(combinedConfig);
const finalConfig = headplaneConfig(combinedConfig); const finalConfig = headplaneConfig(combinedConfig);
if (finalConfig instanceof type.errors) { if (finalConfig instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', { throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: finalConfig.map((e) => e.toString()), messages: finalConfig.map((e) => e.toString()),
}); });
} }
return finalConfig; return finalConfig;
} }
/** /**
@@ -57,23 +60,23 @@ export async function loadConfig(configPathOverride?: string) {
* @throws {Error} If there are validation errors in the loaded configuration * @throws {Error} If there are validation errors in the loaded configuration
*/ */
export async function loadConfigFile(path: string) { export async function loadConfigFile(path: string) {
try { try {
await access(path, constants.R_OK); await access(path, constants.R_OK);
} catch { } catch {
log.info('config', 'Could not access config file at path: %s', path); log.info("config", "Could not access config file at path: %s", path);
return; return;
} }
const rawBuffer = await readFile(path, 'utf8'); const rawBuffer = await readFile(path, "utf8");
const rawConfig = load(rawBuffer); const rawConfig = load(rawBuffer);
const config = partialHeadplaneConfig(rawConfig); const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) { if (config instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', { throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: config.map((e) => e.toString()), messages: config.map((e) => e.toString()),
}); });
} }
return config; return config;
} }
/** /**
@@ -86,36 +89,36 @@ export async function loadConfigFile(path: string) {
* @throws {Error} If there are validation errors in the loaded configuration * @throws {Error} If there are validation errors in the loaded configuration
*/ */
export async function loadConfigEnv() { export async function loadConfigEnv() {
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) { if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
log.warn( log.warn(
'config', "config",
'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions', "HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions",
); );
log.warn( log.warn(
'config', "config",
'Environment variables are always loaded and `.env` files are no longer supported', "Environment variables are always loaded and `.env` files are no longer supported",
); );
} }
const rawConfig: Record<string, unknown> = {}; const rawConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
if (value == null || !key.startsWith('HEADPLANE_')) { if (value == null || !key.startsWith("HEADPLANE_")) {
continue; continue;
} }
const parsedValue = parseEnvValue(value); const parsedValue = parseEnvValue(value);
const configKey = key.slice('HEADPLANE_'.length).toLowerCase(); const configKey = key.slice("HEADPLANE_".length).toLowerCase();
deepSet(rawConfig, configKey.split('__'), parsedValue); deepSet(rawConfig, configKey.split("__"), parsedValue);
} }
const config = partialHeadplaneConfig(rawConfig); const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) { if (config instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', { throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: config.map((e) => e.toString()), messages: config.map((e) => e.toString()),
}); });
} }
return Object.keys(config).length > 0 ? config : undefined; return Object.keys(config).length > 0 ? config : undefined;
} }
/** /**
@@ -126,29 +129,25 @@ export async function loadConfigEnv() {
* @returns The merged object * @returns The merged object
*/ */
function deepMerge<T>(...objects: (T | undefined)[]): T { function deepMerge<T>(...objects: (T | undefined)[]): T {
const result: { [key: string]: unknown } = {}; const result: { [key: string]: unknown } = {};
for (const obj of objects.filter((o) => o != null)) { for (const obj of objects.filter((o) => o != null)) {
for (const [key, value] of Object.entries( for (const [key, value] of Object.entries(
obj as { obj as {
[key: string]: unknown; [key: string]: unknown;
}, },
)) { )) {
if (value != null && typeof value === 'object' && !Array.isArray(value)) { if (value != null && typeof value === "object" && !Array.isArray(value)) {
if ( if (result[key] == null || typeof result[key] !== "object" || Array.isArray(result[key])) {
result[key] == null || result[key] = {};
typeof result[key] !== 'object' || }
Array.isArray(result[key]) result[key] = deepMerge(result[key], value);
) { } else {
result[key] = {}; result[key] = value;
} }
result[key] = deepMerge(result[key], value); }
} else { }
result[key] = value;
}
}
}
return result as T; return result as T;
} }
/** /**
@@ -158,22 +157,18 @@ function deepMerge<T>(...objects: (T | undefined)[]): T {
* @param path An array of keys representing the path to set * @param path An array of keys representing the path to set
* @param value The value to set at the specified path * @param value The value to set at the specified path
*/ */
function deepSet( function deepSet(obj: { [key: string]: unknown }, path: string[], value: unknown): void {
obj: { [key: string]: unknown }, let current = obj;
path: string[], for (let i = 0; i < path.length - 1; i++) {
value: unknown, const key = path[i];
): void { if (current[key] == null || typeof current[key] !== "object") {
let current = obj; current[key] = {};
for (let i = 0; i < path.length - 1; i++) { }
const key = path[i];
if (current[key] == null || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as { [key: string]: unknown }; current = current[key] as { [key: string]: unknown };
} }
current[path[path.length - 1]] = value; current[path[path.length - 1]] = value;
} }
/** /**
@@ -184,18 +179,18 @@ function deepSet(
* @returns The parsed value * @returns The parsed value
*/ */
function parseEnvValue(value: string): unknown { function parseEnvValue(value: string): unknown {
const v = value.trim().toLowerCase(); const v = value.trim().toLowerCase();
if (v === 'true') return true; if (v === "true") return true;
if (v === 'false') return false; if (v === "false") return false;
if (v === 'null') return null; if (v === "null") return null;
if (v === 'undefined') return undefined; if (v === "undefined") return undefined;
if (/^-?\d+(\.\d+)?$/.test(v)) { if (/^-?\d+(\.\d+)?$/.test(v)) {
const num = Number(v); const num = Number(v);
if (!Number.isNaN(num)) return num; if (!Number.isNaN(num)) return num;
} }
return value; return value;
} }
/** /**
@@ -207,43 +202,43 @@ function parseEnvValue(value: string): unknown {
* @param partial The partial configuration object to update * @param partial The partial configuration object to update
*/ */
export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) { export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
for (const key of pathSupportedKeys) { for (const key of pathSupportedKeys) {
const pathKey = `${key}_path`; const pathKey = `${key}_path`;
const pathValue = deepGet(partial, pathKey.split('.')); const pathValue = deepGet(partial, pathKey.split("."));
const existing = deepGet(partial, key.split('.')); const existing = deepGet(partial, key.split("."));
if (pathValue == null || typeof pathValue !== 'string') { if (pathValue == null || typeof pathValue !== "string") {
continue; continue;
} }
if (existing != null) { if (existing != null) {
throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', { throw ConfigError.from("CONFLICTING_SECRET_PATH_FIELD", {
fieldName: key, fieldName: key,
}); });
} }
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => { const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
const value = process.env[variableName]; const value = process.env[variableName];
if (value === undefined) { if (value === undefined) {
throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', { throw ConfigError.from("MISSING_INTERPOLATION_VARIABLE", {
pathKey: `${key}_path`, pathKey: `${key}_path`,
variableName: variableName, variableName: variableName,
}); });
} }
return value; return value;
}); });
try { try {
const fileContent = await readFile(realPath, 'utf8'); const fileContent = await readFile(realPath, "utf8");
deepSet(partial, key.split('.'), fileContent.trim().normalize()); deepSet(partial, key.split("."), fileContent.trim().normalize());
} catch { } catch {
throw ConfigError.from('MISSING_SECRET_FILE', { throw ConfigError.from("MISSING_SECRET_FILE", {
pathKey: `${key}_path`, pathKey: `${key}_path`,
filePath: realPath, filePath: realPath,
}); });
} }
} }
} }
/** /**
@@ -254,14 +249,14 @@ export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
* @returns The value at the specified path or undefined if not found * @returns The value at the specified path or undefined if not found
*/ */
function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown { function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown {
let current = obj; let current = obj;
for (const segment of path) { for (const segment of path) {
if (current == null || typeof current !== 'object') { if (current == null || typeof current !== "object") {
return undefined; return undefined;
} }
current = current[segment] as { [key: string]: unknown }; current = current[segment] as { [key: string]: unknown };
} }
return current; return current;
} }
+7 -6
View File
@@ -1,9 +1,10 @@
import type { Traversal } from 'arktype'; import type { Traversal } from "arktype";
import log from '~/utils/log';
import log from "~/utils/log";
export function deprecatedField() { export function deprecatedField() {
return (_: unknown, ctx: Traversal) => { return (_: unknown, ctx: Traversal) => {
log.warn('config', `${ctx.propString} is deprecated and has no effect.`); log.warn("config", `${ctx.propString} is deprecated and has no effect.`);
return true; return true;
}; };
} }
+13 -16
View File
@@ -1,23 +1,20 @@
import type { Key } from '~/types'; import type { Key } from "~/types";
import { defineApiEndpoints } from '../factory';
import { defineApiEndpoints } from "../factory";
export interface ApiKeyEndpoints { export interface ApiKeyEndpoints {
/** /**
* Retrieves all API keys from the Headscale instance. * Retrieves all API keys from the Headscale instance.
* *
* @returns An array of `Key` objects representing the API keys. * @returns An array of `Key` objects representing the API keys.
*/ */
getApiKeys(): Promise<Key[]>; getApiKeys(): Promise<Key[]>;
} }
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => { getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>( const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
'GET',
'v1/apikey',
apiKey,
);
return apiKeys; return apiKeys;
}, },
})); }));
+42 -46
View File
@@ -1,56 +1,54 @@
import { import {
composeEndpoints, composeEndpoints,
defineApiEndpoints, defineApiEndpoints,
type ExtractApiEndpoints, type ExtractApiEndpoints,
type UnionToIntersection, type UnionToIntersection,
} from '../factory'; } from "../factory";
import type { HeadscaleApiInterface } from '../index'; import type { HeadscaleApiInterface } from "../index";
import apiKeyEndpoints from './api-keys'; import apiKeyEndpoints from "./api-keys";
import nodeEndpoints from './nodes'; import nodeEndpoints from "./nodes";
import policyEndpoints from './policy'; import policyEndpoints from "./policy";
import preAuthKeyEndpoints from './pre-auth-keys'; import preAuthKeyEndpoints from "./pre-auth-keys";
import userEndpoints from './users'; import userEndpoints from "./users";
interface HealthcheckEndpoint { interface HealthcheckEndpoint {
/** /**
* Checks if the Headscale instance is healthy. * Checks if the Headscale instance is healthy.
* *
* @returns A boolean indicating if the instance is healthy. * @returns A boolean indicating if the instance is healthy.
*/ */
isHealthy(): Promise<boolean>; isHealthy(): Promise<boolean>;
} }
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>( const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>((client, apiKey) => ({
(client, apiKey) => ({ isHealthy: async () => {
isHealthy: async () => { try {
try { const res = await client.rawFetch("/health", {
const res = await client.rawFetch('/health', { method: "GET",
method: 'GET', headers: {
headers: { // This doesn't really matter
// This doesn't really matter Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${apiKey}`, },
}, });
});
return res.statusCode === 200; return res.statusCode === 200;
} catch { } catch {
return false; return false;
} }
}, },
}), }));
);
/** /**
* A constant list of all endpoint groups. * A constant list of all endpoint groups.
* Add new endpoint groups here. * Add new endpoint groups here.
*/ */
export const endpointSets = [ export const endpointSets = [
apiKeyEndpoints, apiKeyEndpoints,
healthcheckEndpoint, healthcheckEndpoint,
nodeEndpoints, nodeEndpoints,
policyEndpoints, policyEndpoints,
preAuthKeyEndpoints, preAuthKeyEndpoints,
userEndpoints, userEndpoints,
] as const; ] as const;
/** /**
@@ -63,7 +61,7 @@ export const endpointSets = [
* passing in different internal implementations based on the OpenAPI spec. * passing in different internal implementations based on the OpenAPI spec.
*/ */
export type RuntimeApiClient = UnionToIntersection< export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]> ExtractApiEndpoints<(typeof endpointSets)[number]>
>; >;
/** /**
@@ -73,7 +71,5 @@ export type RuntimeApiClient = UnionToIntersection<
* @param apiKey - The API key for authentication. * @param apiKey - The API key for authentication.
* @returns A fully composed runtime API client. * @returns A fully composed runtime API client.
*/ */
export default ( export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) =>
client: HeadscaleApiInterface['clientHelpers'], composeEndpoints(endpointSets, client, apiKey);
apiKey: string,
) => composeEndpoints(endpointSets, client, apiKey);
@@ -1,7 +1,6 @@
import type { Machine } from "~/types"; import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from ".."; import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory"; import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> { interface RawMachine extends Omit<Machine, "tags"> {
+31 -31
View File
@@ -1,41 +1,41 @@
import { defineApiEndpoints } from '../factory'; import { defineApiEndpoints } from "../factory";
export interface PolicyEndpoints { export interface PolicyEndpoints {
/** /**
* Retrieves the current ACL policy from the Headscale instance. * Retrieves the current ACL policy from the Headscale instance.
* *
* @returns The ACL policy as a string and the date it was last updated. * @returns The ACL policy as a string and the date it was last updated.
*/ */
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>; getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
/** /**
* Sets the ACL policy for the Headscale instance. * Sets the ACL policy for the Headscale instance.
* *
* @param policy The ACL policy as a string. * @param policy The ACL policy as a string.
* @returns The updated ACL policy as a string and the date it was last updated. * @returns The updated ACL policy as a string and the date it was last updated.
*/ */
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>; setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
} }
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
getPolicy: async () => { getPolicy: async () => {
const { policy, updatedAt } = await client.apiFetch<{ const { policy, updatedAt } = await client.apiFetch<{
policy: string; policy: string;
updatedAt: string; updatedAt: string;
}>('GET', 'v1/policy', apiKey); }>("GET", "v1/policy", apiKey);
return { return {
policy, policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null, updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
}; };
}, },
setPolicy: async (policy) => { setPolicy: async (policy) => {
const { policy: newPolicy, updatedAt } = await client.apiFetch<{ const { policy: newPolicy, updatedAt } = await client.apiFetch<{
policy: string; policy: string;
updatedAt: string; updatedAt: string;
}>('PUT', 'v1/policy', apiKey, { policy }); }>("PUT", "v1/policy", apiKey, { policy });
return { policy: newPolicy, updatedAt: new Date(updatedAt) }; return { policy: newPolicy, updatedAt: new Date(updatedAt) };
}, },
})); }));
+67 -72
View File
@@ -1,88 +1,83 @@
import type { User } from '~/types'; import type { User } from "~/types";
import { defineApiEndpoints } from '../factory';
import { defineApiEndpoints } from "../factory";
export interface UserEndpoints { export interface UserEndpoints {
/** /**
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
* *
* @param id Optional ID of the user to retrieve. * @param id Optional ID of the user to retrieve.
* @param name Optional name of the user to retrieve. * @param name Optional name of the user to retrieve.
* @param email Optional email of the user to retrieve. * @param email Optional email of the user to retrieve.
* @returns An array of `User` objects representing the users. * @returns An array of `User` objects representing the users.
*/ */
getUsers(id?: string, name?: string, email?: string): Promise<User[]>; getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
/** /**
* Creates a new user in the Headscale instance. * Creates a new user in the Headscale instance.
* *
* @param username The username of the new user. * @param username The username of the new user.
* @param email Optional email of the new user. * @param email Optional email of the new user.
* @param displayName Optional display name of the new user. * @param displayName Optional display name of the new user.
* @param pictureUrl Optional picture URL of the new user. * @param pictureUrl Optional picture URL of the new user.
* @returns A `User` object representing the newly created user. * @returns A `User` object representing the newly created user.
*/ */
createUser( createUser(
username: string, username: string,
email?: string, email?: string,
displayName?: string, displayName?: string,
pictureUrl?: string, pictureUrl?: string,
): Promise<User>; ): Promise<User>;
/** /**
* Deletes a specific user by its ID. * Deletes a specific user by its ID.
* *
* @param id The ID of the user to delete. * @param id The ID of the user to delete.
*/ */
deleteUser(id: string): Promise<void>; deleteUser(id: string): Promise<void>;
/** /**
* Renames a specific user by its ID. * Renames a specific user by its ID.
* *
* @param id The ID of the user to rename. * @param id The ID of the user to rename.
* @param newName The new name for the user. * @param newName The new name for the user.
*/ */
renameUser(id: string, newName: string): Promise<void>; renameUser(id: string, newName: string): Promise<void>;
} }
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
getUsers: async (id, name, email) => { getUsers: async (id, name, email) => {
const moreThanOneFilter = const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
[id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) { if (moreThanOneFilter) {
throw new Error('Only one of id, name, or email filters can be provided'); throw new Error("Only one of id, name, or email filters can be provided");
} }
const { users } = await client.apiFetch<{ users: User[] }>( const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
'GET', id,
'v1/user', name,
apiKey, email,
{ id, name, email }, });
);
return users; return users;
}, },
createUser: async (username, email, displayName, pictureUrl) => { createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>( const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
'POST', name: username,
'v1/user', email,
apiKey, displayName,
{ name: username, email, displayName, pictureUrl }, pictureUrl,
); });
return user; return user;
}, },
deleteUser: async (id) => { deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey); await client.apiFetch<void>("DELETE", `v1/user/${id}`, apiKey);
}, },
renameUser: async (oldId, newName) => { renameUser: async (oldId, newName) => {
await client.apiFetch<void>( await client.apiFetch<void>("POST", `v1/user/${oldId}/rename/${newName}`, apiKey);
'POST', },
`v1/user/${oldId}/rename/${newName}`,
apiKey,
);
},
})); }));
+33 -42
View File
@@ -1,13 +1,13 @@
import type { HeadscaleConnectionError } from './error'; import type { HeadscaleConnectionError } from "./error";
/** /**
* Represents an error returned by the Headscale API. * Represents an error returned by the Headscale API.
*/ */
export interface HeadscaleAPIError { export interface HeadscaleAPIError {
requestUrl: `${string} ${string}`; requestUrl: `${string} ${string}`;
statusCode: number; statusCode: number;
rawData: string; rawData: string;
data: Record<string, unknown> | null; data: Record<string, unknown> | null;
} }
/** /**
@@ -16,14 +16,14 @@ export interface HeadscaleAPIError {
* @returns True if the error is a HeadscaleAPIError, false otherwise. * @returns True if the error is a HeadscaleAPIError, false otherwise.
*/ */
export function isApiError(error: unknown): error is HeadscaleAPIError { export function isApiError(error: unknown): error is HeadscaleAPIError {
return ( return (
error != null && error != null &&
typeof error === 'object' && typeof error === "object" &&
'requestUrl' in error && "requestUrl" in error &&
'statusCode' in error && "statusCode" in error &&
'rawData' in error && "rawData" in error &&
'data' in error "data" in error
); );
} }
/** /**
@@ -31,17 +31,15 @@ export function isApiError(error: unknown): error is HeadscaleAPIError {
* @param error - The error to check. * @param error - The error to check.
* @returns True if the error is a HeadscaleConnectionError, false otherwise. * @returns True if the error is a HeadscaleConnectionError, false otherwise.
*/ */
export function isConnectionError( export function isConnectionError(error: unknown): error is HeadscaleConnectionError {
error: unknown, return (
): error is HeadscaleConnectionError { error != null &&
return ( typeof error === "object" &&
error != null && "requestUrl" in error &&
typeof error === 'object' && "errorCode" in error &&
'requestUrl' in error && "errorMessage" in error &&
'errorCode' in error && "extraData" in error
'errorMessage' in error && );
'extraData' in error
);
} }
/** /**
@@ -52,15 +50,15 @@ export function isConnectionError(
* @returns True if the error is a DataUnauthorizedError, false otherwise. * @returns True if the error is a DataUnauthorizedError, false otherwise.
*/ */
export function isDataUnauthorizedError(error: unknown): boolean { export function isDataUnauthorizedError(error: unknown): boolean {
return ( return (
error != null && error != null &&
typeof error === 'object' && typeof error === "object" &&
'data' in error && "data" in error &&
typeof error.data === 'object' && typeof error.data === "object" &&
error.data != null && error.data != null &&
'statusCode' in error.data && "statusCode" in error.data &&
error.data.statusCode === 401 error.data.statusCode === 401
); );
} }
/** /**
@@ -72,13 +70,6 @@ export function isDataUnauthorizedError(error: unknown): boolean {
* @returns True if the error is a DataWithResponseInit containing a * @returns True if the error is a DataWithResponseInit containing a
* HeadscaleAPIError, false otherwise. * HeadscaleAPIError, false otherwise.
*/ */
export function isDataWithApiError( export function isDataWithApiError(error: unknown): error is { data: HeadscaleAPIError } {
error: unknown, return error != null && typeof error === "object" && "data" in error && isApiError(error.data);
): error is { data: HeadscaleAPIError } {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
isApiError(error.data)
);
} }
+38 -43
View File
@@ -1,4 +1,4 @@
import { errors } from 'undici'; import { errors } from "undici";
/** /**
* Helper function that determines if an error is a Node.js exception * Helper function that determines if an error is a Node.js exception
@@ -6,22 +6,17 @@ import { errors } from 'undici';
* @returns True if the error is a Node.js exception, false otherwise * @returns True if the error is a Node.js exception, false otherwise
*/ */
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException { function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
return ( return error != null && typeof error === "object" && "code" in error && "errno" in error;
error != null &&
typeof error === 'object' &&
'code' in error &&
'errno' in error
);
} }
/** /**
* A friendly error representation for Headscale connection issues. * A friendly error representation for Headscale connection issues.
*/ */
export interface HeadscaleConnectionError { export interface HeadscaleConnectionError {
requestUrl: string; requestUrl: string;
errorCode: string; errorCode: string;
errorMessage: string; errorMessage: string;
extraData: Record<string, unknown> | null; extraData: Record<string, unknown> | null;
} }
/** /**
@@ -33,40 +28,40 @@ export interface HeadscaleConnectionError {
* @returns A friendly HeadscaleAPIError. * @returns A friendly HeadscaleAPIError.
*/ */
export function undiciToFriendlyError( export function undiciToFriendlyError(
error: unknown, error: unknown,
requestUrl: string, requestUrl: string,
): HeadscaleConnectionError { ): HeadscaleConnectionError {
// MARK: Do we need to go deeper into causes here? // MARK: Do we need to go deeper into causes here?
if (error instanceof AggregateError) { if (error instanceof AggregateError) {
error = error.errors[0]; error = error.errors[0];
} }
if (error instanceof errors.UndiciError) { if (error instanceof errors.UndiciError) {
return { return {
requestUrl, requestUrl,
errorCode: error.code, errorCode: error.code,
errorMessage: error.message, errorMessage: error.message,
extraData: null, extraData: null,
}; };
} }
if (isNodeNetworkError(error)) { if (isNodeNetworkError(error)) {
return { return {
requestUrl, requestUrl,
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR', errorCode: error.code ?? "UNKNOWN_NODE_NETWORK_ERROR",
errorMessage: error.message, errorMessage: error.message,
extraData: { extraData: {
syscall: error.syscall, syscall: error.syscall,
path: error.path, path: error.path,
errno: error.errno, errno: error.errno,
}, },
}; };
} }
return { return {
requestUrl, requestUrl,
errorCode: 'UNKNOWN_ERROR', errorCode: "UNKNOWN_ERROR",
errorMessage: 'An unknown error occured', errorMessage: "An unknown error occured",
extraData: null, extraData: null,
}; };
} }
+16 -23
View File
@@ -1,4 +1,4 @@
import type { HeadscaleApiInterface } from '../api'; import type { HeadscaleApiInterface } from "../api";
/** /**
* Creates a strongly-typed group factory for a given endpoint interface. * Creates a strongly-typed group factory for a given endpoint interface.
@@ -8,38 +8,31 @@ import type { HeadscaleApiInterface } from '../api';
*/ */
export interface EndpointFactory<T extends object> { export interface EndpointFactory<T extends object> {
__type?: T; __type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T; (client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
} }
export function defineApiEndpoints<T extends object>( export function defineApiEndpoints<T extends object>(
factories: ( factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
): EndpointFactory<T> { ): EndpointFactory<T> {
return factories; return factories;
} }
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T> export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T> ? T : never;
? T export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
: never; k: infer I,
export type UnionToIntersection<U> = ( ) => void
U extends any ? I
? (k: U) => void : never;
: never
) extends (k: infer I) => void
? I
: never;
/** /**
* Compose multiple endpoint sets into a single typed runtime client * Compose multiple endpoint sets into a single typed runtime client
*/ */
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>( export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
factories: T, factories: T,
clientHelpers: any, clientHelpers: any,
apiKey: string, apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> { ): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey)); const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any; return Object.assign({}, ...instances) as any;
} }
+31 -32
View File
@@ -1,12 +1,13 @@
import { createHash } from 'node:crypto'; import { createHash } from "node:crypto";
import { dereference } from '@readme/openapi-parser';
import { OpenAPIV2 } from 'openapi-types'; import { dereference } from "@readme/openapi-parser";
import { OpenAPIV2 } from "openapi-types";
/** /**
* A map of operation IDs to their hashes. * A map of operation IDs to their hashes.
*/ */
export interface DocumentHash { export interface DocumentHash {
[operationId: string]: string; [operationId: string]: string;
} }
/** /**
@@ -17,36 +18,34 @@ export interface DocumentHash {
* @param doc The OpenAPI v2 document to hash. * @param doc The OpenAPI v2 document to hash.
* @returns A map of operation IDs to their hashes. * @returns A map of operation IDs to their hashes.
*/ */
export async function hashOpenApiDocument( export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise<DocumentHash> {
doc: OpenAPIV2.Document, const spec = await dereference(doc);
): Promise<DocumentHash> { const hashes: DocumentHash = {};
const spec = await dereference(doc); const seen = new Set<string>();
const hashes: DocumentHash = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) { for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) { for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== 'object') { if (typeof operation !== "object") {
continue; continue;
} }
const { parameters, responses } = operation as OpenAPIV2.OperationObject; const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify( const raw = JSON.stringify(
{ {
path, path,
method: method.toUpperCase(), method: method.toUpperCase(),
parameters, parameters,
responses, responses,
}, },
Object.keys({ path, method, parameters, responses }).sort(), Object.keys({ path, method, parameters, responses }).sort(),
); );
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16); const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final); seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final; hashes[`${method.toUpperCase()} ${path}`] = final;
} }
} }
return hashes; return hashes;
} }
+52 -69
View File
@@ -1,6 +1,6 @@
import canonicals from '~/openapi-canonical-families.json'; import canonicals from "~/openapi-canonical-families.json";
import hashes from '~/openapi-operation-hashes.json'; import hashes from "~/openapi-operation-hashes.json";
import log from '~/utils/log'; import log from "~/utils/log";
/** /**
* The known API versions based on operation hashes. * The known API versions based on operation hashes.
@@ -15,80 +15,63 @@ const VERSIONS = Object.keys(hashes) as Version[];
* @param observed - A mapping of operation identifiers to their hashes. * @param observed - A mapping of operation identifiers to their hashes.
* @returns The detected API version. * @returns The detected API version.
*/ */
export function detectApiVersion( export function detectApiVersion(observed: Record<string, string> | null): Version {
observed: Record<string, string> | null, if (!observed) {
): Version { const latest = VERSIONS.at(-1)!;
if (!observed) { log.warn("api", "No operation hashes observed, defaulting to version %s", latest);
const latest = VERSIONS.at(-1)!; return latest;
log.warn( }
'api',
'No operation hashes observed, defaulting to version %s',
latest,
);
return latest;
}
let bestVersion: Version | null = null; let bestVersion: Version | null = null;
let bestScore = -1; let bestScore = -1;
for (const [version, known] of Object.entries(hashes) as [ for (const [version, known] of Object.entries(hashes) as [Version, Record<string, string>][]) {
Version, let score = 0;
Record<string, string>, for (const [op, hash] of Object.entries(observed)) {
][]) { if (known[op] === hash) score++;
let score = 0; }
for (const [op, hash] of Object.entries(observed)) { if (score > bestScore) {
if (known[op] === hash) score++; bestVersion = version;
} bestScore = score;
if (score > bestScore) { }
bestVersion = version; }
bestScore = score;
}
}
if (!bestVersion || bestScore === 0) { if (!bestVersion || bestScore === 0) {
const latest = VERSIONS.at(-1)!; const latest = VERSIONS.at(-1)!;
log.warn( log.warn("api", "Could not determine API version, defaulting to %s", latest);
'api', return latest;
'Could not determine API version, defaulting to %s', }
latest,
);
return latest;
}
if (bestScore < Object.keys(observed).length) { if (bestScore < Object.keys(observed).length) {
log.warn( log.warn(
'api', "api",
'Partial version match: %d/%d endpoints for version %s', "Partial version match: %d/%d endpoints for version %s",
bestScore, bestScore,
Object.keys(observed).length, Object.keys(observed).length,
bestVersion, bestVersion,
); );
} }
const canonical = Object.entries(canonicals).find(([_, family]) => const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion), family.includes(bestVersion),
)?.[0] as Version | undefined; )?.[0] as Version | undefined;
if (!canonical) { if (!canonical) {
log.warn( log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
'api',
'Could not canonicalize detected version %s, using as-is',
bestVersion,
);
return bestVersion; return bestVersion;
} }
if (canonical !== bestVersion) { if (canonical !== bestVersion) {
log.info( log.info(
'api', "api",
'Canonicalizing detected version %s → %s (same schema)', "Canonicalizing detected version %s → %s (same schema)",
bestVersion, bestVersion,
canonical, canonical,
); );
} }
return canonical; return canonical;
} }
/** /**
@@ -99,5 +82,5 @@ export function detectApiVersion(
* @returns True if current is at least baseline, false otherwise. * @returns True if current is at least baseline, false otherwise.
*/ */
export function isAtLeast(current: Version, baseline: Version) { export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline); return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
} }
+84 -92
View File
@@ -1,11 +1,12 @@
import { access, constants, readFile, writeFile } from 'node:fs/promises'; import { access, constants, readFile, writeFile } from "node:fs/promises";
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from "node:timers/promises";
import log from '~/utils/log';
import log from "~/utils/log";
export interface DNSRecord { export interface DNSRecord {
type: 'A' | 'AAAA' | (string & {}); type: "A" | "AAAA" | (string & {});
name: string; name: string;
value: string; value: string;
} }
// This class is solely for DNS records that are out of tree in the main // This class is solely for DNS records that are out of tree in the main
@@ -15,113 +16,104 @@ export interface DNSRecord {
// All DNS insertions and deletions are handled by the main config manager, // All DNS insertions and deletions are handled by the main config manager,
// but are passed through to here if the extra file is being used. // but are passed through to here if the extra file is being used.
export class HeadscaleDNSConfig { export class HeadscaleDNSConfig {
private records: DNSRecord[]; private records: DNSRecord[];
private access: 'rw' | 'ro' | 'no'; private access: "rw" | "ro" | "no";
private path?: string; private path?: string;
private writeLock = false; private writeLock = false;
constructor( constructor(access: "rw" | "ro" | "no", records?: DNSRecord[], path?: string) {
access: 'rw' | 'ro' | 'no', this.access = access;
records?: DNSRecord[], this.records = records ?? [];
path?: string, this.path = path;
) { }
this.access = access;
this.records = records ?? [];
this.path = path;
}
readable() { readable() {
return this.access !== 'no'; return this.access !== "no";
} }
writable() { writable() {
return this.access === 'rw'; return this.access === "rw";
} }
get r() { get r() {
return this.records; return this.records;
} }
async patch(records: DNSRecord[]) { async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) { if (!this.path || !this.readable() || !this.writable()) {
return; return;
} }
this.records = records; this.records = records;
log.debug( log.debug("config", "Patching DNS records (%d -> %d)", this.records.length, records.length);
'config',
'Patching DNS records (%d -> %d)',
this.records.length,
records.length,
);
return this.write(); return this.write();
} }
private async write() { private async write() {
if (!this.path || !this.writable()) { if (!this.path || !this.writable()) {
return; return;
} }
while (this.writeLock) { while (this.writeLock) {
await setTimeout(100); await setTimeout(100);
} }
this.writeLock = true; this.writeLock = true;
log.debug('config', 'Writing updated DNS configuration to %s', this.path); log.debug("config", "Writing updated DNS configuration to %s", this.path);
const data = JSON.stringify(this.records, null, 4); const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data); await writeFile(this.path, data);
this.writeLock = false; this.writeLock = false;
} }
} }
export async function loadHeadscaleDNS(path?: string) { export async function loadHeadscaleDNS(path?: string) {
if (!path) { if (!path) {
return; return;
} }
log.debug('config', 'Loading Headscale DNS configuration file: %s', path); log.debug("config", "Loading Headscale DNS configuration file: %s", path);
const { w, r } = await validateConfigPath(path); const { w, r } = await validateConfigPath(path);
if (!r) { if (!r) {
return new HeadscaleDNSConfig('no'); return new HeadscaleDNSConfig("no");
} }
const records = await loadConfigFile(path); const records = await loadConfigFile(path);
if (!records) { if (!records) {
return new HeadscaleDNSConfig('no'); return new HeadscaleDNSConfig("no");
} }
return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path); return new HeadscaleDNSConfig(w ? "rw" : "ro", records, path);
} }
async function validateConfigPath(path: string) { async function validateConfigPath(path: string) {
try { try {
await access(path, constants.F_OK | constants.R_OK); await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid Headscale DNS file at %s', path); log.info("config", "Found a valid Headscale DNS file at %s", path);
} catch (error) { } catch (error) {
log.error('config', 'Unable to read a Headscale DNS file at %s', path); log.error("config", "Unable to read a Headscale DNS file at %s", path);
log.error('config', '%s', error); log.error("config", "%s", error);
return { w: false, r: false }; return { w: false, r: false };
} }
try { try {
await access(path, constants.F_OK | constants.W_OK); await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true }; return { w: true, r: true };
} catch (error) { } catch (error) {
log.warn('config', 'Headscale DNS file at %s is not writable', path); log.warn("config", "Headscale DNS file at %s is not writable", path);
return { w: false, r: true }; return { w: false, r: true };
} }
} }
async function loadConfigFile(path: string) { async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale DNS file at %s', path); log.debug("config", "Reading Headscale DNS file at %s", path);
try { try {
const data = await readFile(path, 'utf8'); const data = await readFile(path, "utf8");
const records = JSON.parse(data) as DNSRecord[]; const records = JSON.parse(data) as DNSRecord[];
return records; return records;
} catch (e) { } catch (e) {
log.error('config', 'Error reading Headscale DNS file at %s', path); log.error("config", "Error reading Headscale DNS file at %s", path);
log.error('config', '%s', e); log.error("config", "%s", e);
return false; return false;
} }
} }
+295 -344
View File
@@ -1,422 +1,373 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises'; import { constants, access, readFile, writeFile } from "node:fs/promises";
import { exit } from 'node:process'; import { exit } from "node:process";
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from "node:timers/promises";
import { type } from 'arktype';
import { Document, parseDocument } from 'yaml'; import { type } from "arktype";
import log from '~/utils/log'; import { Document, parseDocument } from "yaml";
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from './config-dns';
import { headscaleConfig } from './config-schema'; import log from "~/utils/log";
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
import { headscaleConfig } from "./config-schema";
interface PatchConfig { interface PatchConfig {
path: string; path: string;
value: unknown; value: unknown;
} }
// We need a class for the config because we need to be able to // We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to // support retrieving it via a getter but also be able to
// patch it and to query it for its mode // patch it and to query it for its mode
class HeadscaleConfig { class HeadscaleConfig {
private config?: typeof headscaleConfig.infer; private config?: typeof headscaleConfig.infer;
private document?: Document; private document?: Document;
private access: 'rw' | 'ro' | 'no'; private access: "rw" | "ro" | "no";
private path?: string; private path?: string;
private writeLock = false; private writeLock = false;
private dns?: HeadscaleDNSConfig; private dns?: HeadscaleDNSConfig;
constructor( constructor(
access: 'rw' | 'ro' | 'no', access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig, dns?: HeadscaleDNSConfig,
config?: typeof headscaleConfig.infer, config?: typeof headscaleConfig.infer,
document?: Document, document?: Document,
path?: string, path?: string,
) { ) {
this.access = access; this.access = access;
this.config = config; this.config = config;
this.document = document; this.document = document;
this.path = path; this.path = path;
this.dns = dns; this.dns = dns;
} }
readable() { readable() {
return this.access !== 'no'; return this.access !== "no";
} }
writable() { writable() {
return this.access === 'rw'; return this.access === "rw";
} }
get c() { get c() {
return this.config; return this.config;
} }
get d() { get d() {
if (this.dns) { if (this.dns) {
return this.dns.r; return this.dns.r;
} }
return this.config?.dns.extra_records ?? []; return this.config?.dns.extra_records ?? [];
} }
async patch(patches: PatchConfig[]) { async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) { if (!this.path || !this.document || !this.readable() || !this.writable()) {
return; return;
} }
log.debug('config', 'Patching Headscale configuration'); log.debug("config", "Patching Headscale configuration");
for (const patch of patches) { for (const patch of patches) {
const { path, value } = patch; const { path, value } = patch;
log.debug('config', 'Patching %s with %o', path, value); log.debug("config", "Patching %s with %o", path, value);
// If the key is something like `test.bar."foo.bar"`, then we treat // If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs // the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly. // to be split correctly.
// Iterate through each character, and if we find a dot, we check if // Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next // the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot. // quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it. // If it's not a quote, we split it.
const key = []; const key = [];
let current = ''; let current = "";
let quote = false; let quote = false;
for (const char of path) { for (const char of path) {
if (char === '"') { if (char === '"') {
quote = !quote; quote = !quote;
} }
if (char === '.' && !quote) { if (char === "." && !quote) {
key.push(current); key.push(current);
current = ''; current = "";
continue; continue;
} }
current += char; current += char;
} }
key.push(current.replaceAll('"', '')); key.push(current.replaceAll('"', ""));
if (value === null) { if (value === null) {
this.document.deleteIn(key); this.document.deleteIn(key);
continue; continue;
} }
this.document.setIn(key, value); this.document.setIn(key, value);
} }
// Revalidate our configuration and update the config // Revalidate our configuration and update the config
// object with the new configuration // object with the new configuration
log.info('config', 'Revalidating Headscale configuration'); log.info("config", "Revalidating Headscale configuration");
const config = validateConfig(this.document.toJSON()); const config = validateConfig(this.document.toJSON());
if (!config) { if (!config) {
return; return;
} }
log.debug( log.debug("config", "Writing updated Headscale configuration to %s", this.path);
'config',
'Writing updated Headscale configuration to %s',
this.path,
);
// We need to lock the writeLock so that we don't try to write // We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it // to the file while we're already writing to it
while (this.writeLock) { while (this.writeLock) {
await setTimeout(100); await setTimeout(100);
} }
this.writeLock = true; this.writeLock = true;
await writeFile(this.path, this.document.toString(), 'utf8'); await writeFile(this.path, this.document.toString(), "utf8");
this.config = config; this.config = config;
this.writeLock = false; this.writeLock = false;
return; return;
} }
/** /**
* Adds a DNS record to the Headscale configuration. * Adds a DNS record to the Headscale configuration.
* Differentiates between the file mode and config mode automatically. * Differentiates between the file mode and config mode automatically.
* @param record The DNS record to add. * @param record The DNS record to add.
* @returns True if we need to restart the integration. * @returns True if we need to restart the integration.
*/ */
async addDNS(record: DNSRecord) { async addDNS(record: DNSRecord) {
if (this.dns) { if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) { if (!this.dns.readable() || !this.dns.writable()) {
log.debug('config', 'DNS config is not writable'); log.debug("config", "DNS config is not writable");
return; return;
} }
const records = this.dns.r; const records = this.dns.r;
if ( if (records.some((i) => i.name === record.name && i.type === record.type)) {
records.some((i) => i.name === record.name && i.type === record.type) log.debug("config", "DNS record already exists");
) { return;
log.debug('config', 'DNS record already exists'); }
return;
}
return this.dns.patch([...records, record]); return this.dns.patch([...records, record]);
} }
// If we get here, we need to add to the main config instead of // If we get here, we need to add to the main config instead of
// a separate file (which requires an integration restart) // a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? []; const existing = this.config?.dns.extra_records ?? [];
if ( if (existing.some((i) => i.name === record.name && i.type === record.type)) {
existing.some((i) => i.name === record.name && i.type === record.type) log.debug("config", "DNS record already exists");
) { return;
log.debug('config', 'DNS record already exists'); }
return;
}
await this.patch([ await this.patch([
{ {
path: 'dns.extra_records', path: "dns.extra_records",
value: Array.from(new Set([...existing, record])), value: Array.from(new Set([...existing, record])),
}, },
]); ]);
return true; return true;
} }
/** /**
* Removes a DNS record from the Headscale configuration. * Removes a DNS record from the Headscale configuration.
* Differentiates between the file mode and config mode automatically. * Differentiates between the file mode and config mode automatically.
* @param records The DNS record to remove. * @param records The DNS record to remove.
* @returns True if we need to restart the integration. * @returns True if we need to restart the integration.
*/ */
async removeDNS(record: DNSRecord) { async removeDNS(record: DNSRecord) {
// In this case we need to check both the main config and the DNS config // In this case we need to check both the main config and the DNS config
// to see if the record exists, and if it does, we need to remove it // to see if the record exists, and if it does, we need to remove it
// from both places. // from both places.
if (this.dns) { if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) { if (!this.dns.readable() || !this.dns.writable()) {
log.debug('config', 'DNS config is not writable'); log.debug("config", "DNS config is not writable");
return; return;
} }
const records = this.dns.r.filter( const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
(i) => i.name !== record.name || i.type !== record.type,
);
return this.dns.patch(records); return this.dns.patch(records);
} }
// If we get here, we need to remove from the main config instead of // If we get here, we need to remove from the main config instead of
// a separate file (which requires an integration restart) // a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? []; const existing = this.config?.dns.extra_records ?? [];
const filtered = existing.filter( const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
(i) => i.name !== record.name || i.type !== record.type,
);
// If the length of the existing records is the same as the filtered // If the length of the existing records is the same as the filtered
// records, then we don't need to do anything // records, then we don't need to do anything
if (existing.length === filtered.length) { if (existing.length === filtered.length) {
return; return;
} }
await this.patch([ await this.patch([
{ {
path: 'dns.extra_records', path: "dns.extra_records",
value: existing.filter( value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
(i) => i.name !== record.name || i.type !== record.type, },
), ]);
},
]);
return true; return true;
} }
} }
export async function loadHeadscaleConfig( export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
path?: string, if (!path) {
strict = true, log.debug("config", "No Headscale configuration file was provided");
dnsPath?: string, return new HeadscaleConfig("no");
) { }
if (!path) {
log.debug('config', 'No Headscale configuration file was provided');
return new HeadscaleConfig('no');
}
log.debug('config', 'Loading Headscale configuration file: %s', path); log.debug("config", "Loading Headscale configuration file: %s", path);
const { r, w } = await validateConfigPath(path); const { r, w } = await validateConfigPath(path);
if (!r) { if (!r) {
return new HeadscaleConfig('no'); return new HeadscaleConfig("no");
} }
const document = await loadConfigFile(path); const document = await loadConfigFile(path);
if (!document) { if (!document) {
return new HeadscaleConfig('no'); return new HeadscaleConfig("no");
} }
if (!strict) { if (!strict) {
return new HeadscaleConfig( return new HeadscaleConfig(
w ? 'rw' : 'ro', w ? "rw" : "ro",
new HeadscaleDNSConfig('no'), new HeadscaleDNSConfig("no"),
augmentUnstrictConfig(document.toJSON()), augmentUnstrictConfig(document.toJSON()),
document, document,
path, path,
); );
} }
const config = validateConfig(document.toJSON()); const config = validateConfig(document.toJSON());
if (!config) { if (!config) {
return new HeadscaleConfig('no'); return new HeadscaleConfig("no");
} }
if (config.dns.extra_records && config.dns.extra_records_path) { if (config.dns.extra_records && config.dns.extra_records_path) {
log.warn( log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
'config',
'Both extra_records and extra_records_path are set, Headscale will crash',
);
log.warn('config', 'Please remove one of them from the configuration file'); log.warn("config", "Please remove one of them from the configuration file");
return new HeadscaleConfig('no'); return new HeadscaleConfig("no");
} }
const dns = await loadHeadscaleDNS(dnsPath); const dns = await loadHeadscaleDNS(dnsPath);
if (dns && !config.dns.extra_records_path) { if (dns && !config.dns.extra_records_path) {
log.error( log.error(
'config', "config",
'Using separate DNS config file but dns.extra_records_path is not set in Headscale config', "Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
); );
log.error( log.error("config", "Please set `dns.extra_records_path` in the Headscale config");
'config', log.error("config", "Or remove `headscale.dns_records_path` from the Headplane config");
'Please set `dns.extra_records_path` in the Headscale config',
);
log.error(
'config',
'Or remove `headscale.dns_records_path` from the Headplane config',
);
exit(1); exit(1);
} }
return new HeadscaleConfig(w ? 'rw' : 'ro', dns, config, document, path); return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
} }
async function validateConfigPath(path: string) { async function validateConfigPath(path: string) {
try { try {
await access(path, constants.F_OK | constants.R_OK); await access(path, constants.F_OK | constants.R_OK);
log.info( log.info("config", "Found a valid Headscale configuration file at %s", path);
'config', } catch (error) {
'Found a valid Headscale configuration file at %s', log.error("config", "Unable to read a Headscale configuration file at %s", path);
path, log.error("config", "%s", error);
); return { w: false, r: false };
} catch (error) { }
log.error(
'config',
'Unable to read a Headscale configuration file at %s',
path,
);
log.error('config', '%s', error);
return { w: false, r: false };
}
try { try {
await access(path, constants.F_OK | constants.W_OK); await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true }; return { w: true, r: true };
} catch (error) { } catch (error) {
log.warn( log.warn("config", "Headscale configuration file at %s is not writable", path);
'config', return { w: false, r: true };
'Headscale configuration file at %s is not writable', }
path,
);
return { w: false, r: true };
}
} }
async function loadConfigFile(path: string) { async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale configuration file at %s', path); log.debug("config", "Reading Headscale configuration file at %s", path);
try { try {
const data = await readFile(path, 'utf8'); const data = await readFile(path, "utf8");
const configYaml = parseDocument(data); const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) { if (configYaml.errors.length > 0) {
log.error( log.error("config", "Cannot parse Headscale configuration file at %s", path);
'config', for (const error of configYaml.errors) {
'Cannot parse Headscale configuration file at %s', log.error("config", ` - ${error.toString()}`);
path, }
);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
return false; return false;
} }
return configYaml; return configYaml;
} catch (e) { } catch (e) {
log.error( log.error("config", "Error reading Headscale configuration file at %s", path);
'config', log.error("config", "%s", e);
'Error reading Headscale configuration file at %s', return false;
path, }
);
log.error('config', '%s', e);
return false;
}
} }
export function validateConfig(config: unknown) { export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headscale configuration'); log.debug("config", "Validating Headscale configuration");
const result = headscaleConfig(config); const result = headscaleConfig(config);
if (result instanceof type.errors) { if (result instanceof type.errors) {
log.error('config', 'Error validating Headscale configuration:'); log.error("config", "Error validating Headscale configuration:");
for (const [number, error] of result.entries()) { for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`); log.error("config", ` - (${number}): ${error.toString()}`);
} }
return; return;
} }
return result; return result;
} }
// If config_strict is false, we set the defaults and disable // If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present // the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) { function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug('config', 'Augmenting Headscale configuration in non-strict mode'); log.debug("config", "Augmenting Headscale configuration in non-strict mode");
const config = { const config = {
...loaded, ...loaded,
tls_letsencrypt_cache_dir: tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache', tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
tls_letsencrypt_challenge_type: grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01', grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443', randomize_client_port: loaded.randomize_client_port ?? false,
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false, unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
randomize_client_port: loaded.randomize_client_port ?? false, unix_socket_permission: loaded.unix_socket_permission ?? "0770",
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
log: loaded.log ?? { log: loaded.log ?? {
level: 'info', level: "info",
format: 'text', format: "text",
}, },
logtail: loaded.logtail ?? { logtail: loaded.logtail ?? {
enabled: false, enabled: false,
}, },
prefixes: loaded.prefixes ?? { prefixes: loaded.prefixes ?? {
allocation: 'sequential', allocation: "sequential",
v4: '', v4: "",
v6: '', v6: "",
}, },
dns: loaded.dns ?? { dns: loaded.dns ?? {
nameservers: { nameservers: {
global: [], global: [],
split: {}, split: {},
}, },
search_domains: [], search_domains: [],
extra_records: [], extra_records: [],
magic_dns: false, magic_dns: false,
base_domain: 'headscale.net', base_domain: "headscale.net",
}, },
}; };
log.warn('config', 'Headscale configuration was loaded in non-strict mode'); log.warn("config", "Headscale configuration was loaded in non-strict mode");
log.warn('config', 'This is very dangerous and comes with a few caveats:'); log.warn("config", "This is very dangerous and comes with a few caveats:");
log.warn('config', ' - Headplane could very easily crash'); log.warn("config", " - Headplane could very easily crash");
log.warn('config', ' - Headplane could break your Headscale installation'); log.warn("config", " - Headplane could break your Headscale installation");
log.warn( log.warn("config", " - The UI could throw random errors/show incorrect data");
'config',
' - The UI could throw random errors/show incorrect data',
);
return config as typeof headscaleConfig.infer; return config as typeof headscaleConfig.infer;
} }
+127 -127
View File
@@ -1,150 +1,150 @@
import { type } from 'arktype'; import { type } from "arktype";
const goBool = type('boolean | "true" | "false"').pipe((v) => { const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true; if (v === "true") return true;
if (v === 'false') return false; if (v === "false") return false;
return v; return v;
}); });
const goDuration = type('0 | string').pipe((v) => { const goDuration = type("0 | string").pipe((v) => {
return v.toString(); return v.toString();
}); });
const databaseConfig = type({ const databaseConfig = type({
type: '"sqlite" | "sqlite3"', type: '"sqlite" | "sqlite3"',
sqlite: { sqlite: {
path: 'string', path: "string",
write_ahead_log: goBool.default(true), write_ahead_log: goBool.default(true),
wal_autocheckpoint: 'number = 1000', wal_autocheckpoint: "number = 1000",
}, },
}) })
.or({ .or({
type: '"postgres"', type: '"postgres"',
postgres: { postgres: {
host: 'string', host: "string",
port: 'number | ""', port: 'number | ""',
name: 'string', name: "string",
user: 'string', user: "string",
pass: 'string', pass: "string",
max_open_conns: 'number = 10', max_open_conns: "number = 10",
max_idle_conns: 'number = 10', max_idle_conns: "number = 10",
conn_max_idle_time_secs: 'number = 3600', conn_max_idle_time_secs: "number = 3600",
ssl: goBool.default(false), ssl: goBool.default(false),
}, },
}) })
.merge({ .merge({
debug: goBool.default(false), debug: goBool.default(false),
'gorm?': { "gorm?": {
prepare_stmt: goBool.default(true), prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true), parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true), skip_err_record_not_found: goBool.default(true),
slow_threshold: 'number = 1000', slow_threshold: "number = 1000",
}, },
}); });
// Not as strict parsing because we just need the values // Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them // to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer; export type HeadscaleConfig = typeof headscaleConfig.infer;
export const headscaleConfig = type({ export const headscaleConfig = type({
server_url: 'string', server_url: "string",
listen_addr: 'string', listen_addr: "string",
'metrics_listen_addr?': 'string', "metrics_listen_addr?": "string",
grpc_listen_addr: 'string = ":50433"', grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false), grpc_allow_insecure: goBool.default(false),
noise: { noise: {
private_key_path: 'string', private_key_path: "string",
}, },
prefixes: { prefixes: {
v4: 'string?', v4: "string?",
v6: 'string?', v6: "string?",
allocation: '"sequential" | "random" = "sequential"', allocation: '"sequential" | "random" = "sequential"',
}, },
derp: { derp: {
server: { server: {
enabled: goBool.default(true), enabled: goBool.default(true),
region_id: 'number?', region_id: "number?",
region_code: 'string?', region_code: "string?",
region_name: 'string?', region_name: "string?",
stun_listen_addr: 'string?', stun_listen_addr: "string?",
private_key_path: 'string?', private_key_path: "string?",
ipv4: 'string?', ipv4: "string?",
ipv6: 'string?', ipv6: "string?",
automatically_add_embedded_derp_region: goBool.default(true), automatically_add_embedded_derp_region: goBool.default(true),
}, },
urls: 'string[]?', urls: "string[]?",
paths: 'string[]?', paths: "string[]?",
auto_update_enabled: goBool.default(true), auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'), update_frequency: goDuration.default("24h"),
}, },
disable_check_updates: goBool.default(false), disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('30m'), ephemeral_node_inactivity_timeout: goDuration.default("30m"),
database: databaseConfig, database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"', acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""', acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""', tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"', tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"', tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"', tls_letsencrypt_listen: 'string = ":http"',
'tls_cert_path?': 'string', "tls_cert_path?": "string",
'tls_key_path?': 'string', "tls_key_path?": "string",
log: type({ log: type({
format: 'string = "text"', format: 'string = "text"',
level: 'string = "info"', level: 'string = "info"',
}).default(() => ({ format: 'text', level: 'info' })), }).default(() => ({ format: "text", level: "info" })),
'policy?': { "policy?": {
mode: '"database" | "file" = "file"', mode: '"database" | "file" = "file"',
path: 'string?', path: "string?",
}, },
dns: { dns: {
magic_dns: goBool.default(true), magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"', base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false), override_local_dns: goBool.default(false),
nameservers: type({ nameservers: type({
global: type('string[]').default(() => []), global: type("string[]").default(() => []),
split: type('Record<string, string[]>').default(() => ({})), split: type("Record<string, string[]>").default(() => ({})),
}).default(() => ({ global: [], split: {} })), }).default(() => ({ global: [], split: {} })),
search_domains: type('string[]').default(() => []), search_domains: type("string[]").default(() => []),
extra_records: type({ extra_records: type({
name: 'string', name: "string",
value: 'string', value: "string",
type: 'string | "A"', type: 'string | "A"',
}) })
.array() .array()
.optional(), .optional(),
extra_records_path: 'string?', extra_records_path: "string?",
}, },
unix_socket: 'string?', unix_socket: "string?",
unix_socket_permission: 'string = "0770"', unix_socket_permission: 'string = "0770"',
'oidc?': { "oidc?": {
only_start_if_oidc_is_available: goBool.default(false), only_start_if_oidc_is_available: goBool.default(false),
issuer: 'string', issuer: "string",
client_id: 'string', client_id: "string",
client_secret: 'string?', client_secret: "string?",
client_secret_path: 'string?', client_secret_path: "string?",
expiry: goDuration.default('180d'), expiry: goDuration.default("180d"),
use_expiry_from_token: goBool.default(false), use_expiry_from_token: goBool.default(false),
scope: type('string[]').default(() => ['openid', 'email', 'profile']), scope: type("string[]").default(() => ["openid", "email", "profile"]),
extra_params: 'Record<string, string>?', extra_params: "Record<string, string>?",
allowed_domains: 'string[]?', allowed_domains: "string[]?",
allowed_groups: 'string[]?', allowed_groups: "string[]?",
allowed_users: 'string[]?', allowed_users: "string[]?",
'pkce?': { "pkce?": {
enabled: goBool.default(false), enabled: goBool.default(false),
method: 'string = "S256"', method: 'string = "S256"',
}, },
map_legacy_users: goBool.default(false), map_legacy_users: goBool.default(false),
}, },
'logtail?': { "logtail?": {
enabled: goBool.default(false), enabled: goBool.default(false),
}, },
randomize_client_port: goBool.default(false), randomize_client_port: goBool.default(false),
}); });
+5 -5
View File
@@ -1,7 +1,7 @@
export type Key = { export type Key = {
id: string; id: string;
prefix: string; prefix: string;
expiration: string; expiration: string;
createdAt: Date; createdAt: Date;
lastSeen: Date; lastSeen: Date;
}; };
+10 -10
View File
@@ -1,13 +1,13 @@
import type { User } from './User'; import type { User } from "./User";
export interface PreAuthKey { export interface PreAuthKey {
id: string; id: string;
key: string; key: string;
user: User | null; user: User | null;
reusable: boolean; reusable: boolean;
ephemeral: boolean; ephemeral: boolean;
used: boolean; used: boolean;
expiration: string; expiration: string;
createdAt: string; createdAt: string;
aclTags: string[]; aclTags: string[];
} }
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine'; import type { Machine } from "./Machine";
export interface Route { export interface Route {
id: string; id: string;
node: Machine; node: Machine;
prefix: string; prefix: string;
advertised: boolean; advertised: boolean;
enabled: boolean; enabled: boolean;
isPrimary: boolean; isPrimary: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string; deletedAt: string;
} }
+8 -8
View File
@@ -1,10 +1,10 @@
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
createdAt: string; createdAt: string;
displayName?: string; displayName?: string;
email?: string; email?: string;
providerId?: string; providerId?: string;
provider?: string; provider?: string;
profilePicUrl?: string; profilePicUrl?: string;
} }
+6 -6
View File
@@ -1,6 +1,6 @@
export * from './Key'; export * from "./Key";
export * from './Machine'; export * from "./Machine";
export * from './Route'; export * from "./Route";
export * from './User'; export * from "./User";
export * from './PreAuthKey'; export * from "./PreAuthKey";
export * from './HostInfo'; export * from "./HostInfo";
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from "clsx";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export default cn; export default cn;
+25 -25
View File
@@ -1,36 +1,36 @@
import type { HostInfo } from '~/types'; import type { HostInfo } from "~/types";
export function getTSVersion(host: HostInfo) { export function getTSVersion(host: HostInfo) {
const { IPNVersion } = host; const { IPNVersion } = host;
if (!IPNVersion) { if (!IPNVersion) {
return 'Unknown'; return "Unknown";
} }
// IPNVersion is <Semver>-<something>-<something> // IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split('-')[0]; return IPNVersion.split("-")[0];
} }
export function getOSInfo(host: HostInfo) { export function getOSInfo(host: HostInfo) {
const { OS, OSVersion } = host; const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin // OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS); const formattedOS = formatOS(OS);
// Trim in case OSVersion is empty // Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion ?? ''}`.trim(); return `${formattedOS} ${OSVersion ?? ""}`.trim();
} }
function formatOS(os?: string) { function formatOS(os?: string) {
switch (os) { switch (os) {
case 'macOS': case "macOS":
case 'iOS': case "iOS":
return os; return os;
case 'windows': case "windows":
return 'Windows'; return "Windows";
case 'linux': case "linux":
return 'Linux'; return "Linux";
case undefined: case undefined:
return 'Unknown'; return "Unknown";
default: default:
return os; return os;
} }
} }
+24 -24
View File
@@ -1,39 +1,39 @@
import { data } from 'react-router'; import { data } from "react-router";
export function send<T>(payload: T, init?: number | ResponseInit) { export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init); return data(payload, init);
} }
export function send401<T>(payload: T) { export function send401<T>(payload: T) {
return data(payload, { status: 401 }); return data(payload, { status: 401 });
} }
export function data400(message: string) { export function data400(message: string) {
return data( return data(
{ {
success: false, success: false,
message, message,
}, },
{ status: 400 }, { status: 400 },
); );
} }
export function data403(message: string) { export function data403(message: string) {
return data( return data(
{ {
success: false, success: false,
message, message,
}, },
{ status: 403 }, { status: 403 },
); );
} }
export function data404(message: string) { export function data404(message: string) {
return data( return data(
{ {
success: false, success: false,
message, message,
}, },
{ status: 404 }, { status: 404 },
); );
} }
+28 -28
View File
@@ -6,37 +6,37 @@
* - Over 1 month: "X months, Y days ago" * - Over 1 month: "X months, Y days ago"
*/ */
export function formatTimeDelta(date: Date): string { export function formatTimeDelta(date: Date): string {
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60)); const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60)); const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
if (minutes < 60) { if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
} }
if (hours < 24) { if (hours < 24) {
const remainingMinutes = minutes % 60; const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) { if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`; return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
} }
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`; return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`;
} }
if (days < 30) { if (days < 30) {
const remainingHours = hours % 24; const remainingHours = hours % 24;
if (remainingHours === 0) { if (remainingHours === 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`; return `${days} day${days !== 1 ? "s" : ""} ago`;
} }
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`; return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`;
} }
const remainingDays = days % 30; const remainingDays = days % 30;
if (remainingDays === 0) { if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`; return `${months} month${months !== 1 ? "s" : ""} ago`;
} }
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`; return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`;
} }
+87 -87
View File
@@ -165,91 +165,91 @@ integration:
# OIDC Configuration for simpler authentication # OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience) # (This is optional, but recommended for the best experience)
# oidc: # oidc:
# Set to false to define OIDC config without enabling it. # # Set to false to define OIDC config without enabling it.
# Useful for Helm charts or generating docs from config files. # # Useful for Helm charts or generating docs from config files.
# enabled: true # enabled: true
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# DEPRECATED: Use headscale.api_key instead.
# If set, this will be used as a fallback for headscale.api_key.
# headscale_api_key: "<your-headscale-api-key>"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
# When true, /logout redirects the user to the IdP's end_session_endpoint
# (auto-discovered or set manually below) so the upstream session is ended too.
# #
# Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered # # The OIDC issuer URL
# in your OIDC client configuration on the IdP. If it isn't, users will land # issuer: "https://accounts.google.com"
# on the provider's error page after logout. #
# use_end_session: false # # DEPRECATED: Use headscale.api_key instead.
# # If set, this will be used as a fallback for headscale.api_key.
# Optional. Override the auto-discovered end_session_endpoint, or supply one # headscale_api_key: "<your-headscale-api-key>"
# if your provider does not advertise it via discovery. #
# end_session_endpoint: "" # # If your OIDC provider does not support discovery (does not have the URL at
# # `/.well-known/openid-configuration`), you need to manually set endpoints.
# Where the identity provider should redirect after RP-initiated logout. # # This also works to override endpoints if you so desire or if your OIDC
# Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered # # discovery is missing certain endpoints (ie GitHub).
# in the OIDC client configuration. If unset, Headplane defaults to its own # # For some typical providers, see https://headplane.net/features/sso.
# `<server.base_url>/admin/login?s=logout` page. # authorization_endpoint: ""
# post_logout_redirect_uri: "" # token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint. #
# This is fully optional and Headplane will attempt to auto-detect the best # # RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
# method and fall back to `client_secret_basic` if unsure. # # When true, /logout redirects the user to the IdP's end_session_endpoint
# token_endpoint_auth_method: "client_secret_post" # # (auto-discovered or set manually below) so the upstream session is ended too.
# #
# The client ID for the OIDC client # # Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered
# For the best experience please ensure this is *identical* to the client_id # # in your OIDC client configuration on the IdP. If it isn't, users will land
# you are using for Headscale. because # # on the provider's error page after logout.
# client_id: "your-client-id" # use_end_session: false
#
# The client secret for the OIDC client # # Optional. Override the auto-discovered end_session_endpoint, or supply one
# You may also provide `client_secret_path` instead to read a value from disk. # # if your provider does not advertise it via discovery.
# See https://headplane.net/configuration/#sensitive-values # end_session_endpoint: ""
# client_secret: "<your-client-secret>" #
# # Where the identity provider should redirect after RP-initiated logout.
# Whether to use PKCE when authenticating users. This is recommended as it # # Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered
# adds an extra layer of security to the authentication process. Enabling this # # in the OIDC client configuration. If unset, Headplane defaults to its own
# means your OIDC provider must support PKCE and it must be enabled on the # # `<server.base_url>/admin/login?s=logout` page.
# client. # post_logout_redirect_uri: ""
# use_pkce: true #
# # The authentication method to use when communicating with the token endpoint.
# If you want to disable traditional login via Headscale API keys # # This is fully optional and Headplane will attempt to auto-detect the best
# disable_api_key_login: false # # method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# By default profile pictures are pulled from the OIDC provider when #
# we go to fetch the userinfo endpoint. Optionally, this can be set to # # The client ID for the OIDC client
# "oidc" or "gravatar" as of 0.6.1. # # For the best experience please ensure this is *identical* to the client_id
# profile_picture_source: "gravatar" # # you are using for Headscale.
# client_id: "your-client-id"
# The scopes to request when authenticating users. The default is below. #
# scope: "openid email profile" # # The client secret for the OIDC client
# # You may also provide `client_secret_path` instead to read a value from disk.
# Optional fallback claims to use when your provider does not return a standard # # See https://headplane.net/configuration/#sensitive-values
# OIDC `sub` claim. Headplane always checks `sub` first, then each claim here # client_secret: "<your-client-secret>"
# in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback. #
# subject_claims: # # Whether to use PKCE when authenticating users. This is recommended as it
# - "open_id" # # adds an extra layer of security to the authentication process. Enabling
# - "email" # # this means your OIDC provider must support PKCE and it must be enabled on
# # the client.
# Allow ID token verification with legacy RSA keys smaller than 2048 bits. # use_pkce: true
# This is disabled by default because it lowers token verification security and #
# should only be used as a temporary compatibility workaround. # # If you want to disable traditional login via Headscale API keys
# allow_weak_rsa_keys: false # disable_api_key_login: false
#
# Extra query parameters can be passed to the authorization endpoint # # By default profile pictures are pulled from the OIDC provider when
# by setting them here. This is useful for providers that require any kind # # we go to fetch the userinfo endpoint. Optionally, this can be set to
# of custom hinting. # # "oidc" or "gravatar" as of 0.6.1.
# extra_params: # profile_picture_source: "gravatar"
# prompt: "select_account" # Example: force account selection on Google #
# # The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
#
# # Optional fallback claims to use when your provider does not return a standard
# # OIDC `sub` claim. Headplane always checks `sub` first, then each claim here
# # in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback.
# subject_claims:
# - "open_id"
# - "email"
#
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
# # This is disabled by default because it lowers token verification security and
# # should only be used as a temporary compatibility workaround.
# allow_weak_rsa_keys: false
#
# # Extra query parameters can be passed to the authorization endpoint
# # by setting them here. This is useful for providers that require any kind
# # of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
+4
View File
@@ -4,12 +4,15 @@ description: Common issues and their solutions
--- ---
# Common Issues and Their Solutions # Common Issues and Their Solutions
This document outlines some common issues users may encounter while using Headplane, along with their solutions. This document outlines some common issues users may encounter while using Headplane, along with their solutions.
## Login does not work ## Login does not work
::: tip ::: tip
Headplane tries to detect misconfigurations and will surface a warning banner on Headplane tries to detect misconfigurations and will surface a warning banner on
the login page if it detects any abnormalities. You may see a banner like this: the login page if it detects any abnormalities. You may see a banner like this:
<figure> <figure>
<img class="dark-only" src="../assets/login-banner-dark.png" /> <img class="dark-only" src="../assets/login-banner-dark.png" />
<img class="light-only" src="../assets/login-banner-light.png" /> <img class="light-only" src="../assets/login-banner-light.png" />
@@ -21,5 +24,6 @@ If you attempt to log in to Headplane but nothing happens, it may be due to a
misconfiguration of the server cookie settings. In your Headplane configuration, misconfiguration of the server cookie settings. In your Headplane configuration,
ensure that `server.cookie_secure` is set appropriately based on how you are ensure that `server.cookie_secure` is set appropriately based on how you are
accessing Headplane: accessing Headplane:
- Serving over HTTPS: `cookie_secure` should be enabled (`true`). - Serving over HTTPS: `cookie_secure` should be enabled (`true`).
- Serving over HTTP: `cookie_secure` should be disabled (`false`). - Serving over HTTP: `cookie_secure` should be disabled (`false`).
-1
View File
@@ -32,4 +32,3 @@ features:
details: "Manage settings hidden behind Headscale configuration such as DNS, networking, auth controls, etc." details: "Manage settings hidden behind Headscale configuration such as DNS, networking, auth controls, etc."
icon: "📝" icon: "📝"
--- ---
+18 -13
View File
@@ -12,15 +12,16 @@ set up your configuration file and then pick the installation method that best
suits your needs. suits your needs.
## Configuration ## Configuration
Headplane requires a configuration file to operate. A Headplane requires a configuration file to operate. A
[sample file](https://github.com/tale/headplane/blob/main/config.example.yaml) [sample file](https://github.com/tale/headplane/blob/main/config.example.yaml)
is available to use as a starting point. Some of the important fields include: is available to use as a starting point. Some of the important fields include:
| Field | Description | | Field | Description |
|---------------------|--------------------------------------------------------| | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). | | **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). |
| **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. | | **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. |
| **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. | | **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. |
The configuration file is rather complicated and has many more options. Refer to The configuration file is rather complicated and has many more options. Refer to
the [Configuration](../configuration/index.md) guide for a detailed explanation of all the [Configuration](../configuration/index.md) guide for a detailed explanation of all
@@ -28,24 +29,28 @@ the available options, as well as guidance on securely setting up your values
through secret path options and environment variables. through secret path options and environment variables.
## Deployment Methods ## Deployment Methods
Headplane can be deployed in several different ways, each with its own set of Headplane can be deployed in several different ways, each with its own set of
advantages and trade-offs. Choose the method that best fits your needs: advantages and trade-offs. Choose the method that best fits your needs:
### [Docker](./docker.md): Fast and easy deployment using Docker ### [Docker](./docker.md): Fast and easy deployment using Docker
- Recommended for most users due to its simplicity and ease of use.
- Allows for advanced features like network management and remote web SSH. - Recommended for most users due to its simplicity and ease of use.
- Requires Docker and Docker Compose to be installed. - Allows for advanced features like network management and remote web SSH.
- Requires Docker and Docker Compose to be installed.
--- ---
### [Native Mode](./native-mode.md): Direct installation on a server ### [Native Mode](./native-mode.md): Direct installation on a server
- Suitable for users who prefer not to use Docker.
- Allows for advanced features like network management and remote web SSH. - Suitable for users who prefer not to use Docker.
- Requires manual setup of dependencies and environment. - Allows for advanced features like network management and remote web SSH.
- Requires manual setup of dependencies and environment.
--- ---
### [Limited Mode](./limited-mode.md): Quick and easy deployment with minimal features ### [Limited Mode](./limited-mode.md): Quick and easy deployment with minimal features
- Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management - Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management
or remote web SSH. or remote web SSH.
+9 -6
View File
@@ -12,16 +12,18 @@ deployment. Limited mode lacks advanced features such as network management,
remote web SSH, and more. remote web SSH, and more.
::: :::
Limited Mode is good for users who want to test out the *basic* functionality Limited Mode is good for users who want to test out the _basic_ functionality
provided by Headplane. It only interacts with the Headplane API and lacks all provided by Headplane. It only interacts with the Headplane API and lacks all
advanced features, making it suitable for local testing and development. advanced features, making it suitable for local testing and development.
## Prerequisites ## Prerequisites
- Docker (and optionally Docker Compose) - Docker (and optionally Docker Compose)
- Headscale version 0.26.0 or later installed and running - Headscale version 0.26.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane. - A [completed configuration file](/index.md#configuration) for Headplane.
## Installation ## Installation
::: tip ::: tip
If you want to test Limited Mode without Docker, you can follow the If you want to test Limited Mode without Docker, you can follow the
[Native Mode](./native-mode.md) installation guide and simply avoid setting [Native Mode](./native-mode.md) installation guide and simply avoid setting
@@ -29,6 +31,7 @@ up any of the advanced features.
::: :::
Running Headplane in Limited Mode is as simple as running 1 command: Running Headplane in Limited Mode is as simple as running 1 command:
```bash ```bash
docker run -d \ docker run -d \
-p 3000:3000 \ -p 3000:3000 \
@@ -44,6 +47,7 @@ storage location for Headplane to store its own data. You can also change the
port mapping if you want to run it on a different port. port mapping if you want to run it on a different port.
### Optional: Docker Compose ### Optional: Docker Compose
If you prefer using Docker Compose, here is a minimal example of a If you prefer using Docker Compose, here is a minimal example of a
`compose.yaml` file that runs Headplane in Limited Mode: `compose.yaml` file that runs Headplane in Limited Mode:
@@ -54,10 +58,10 @@ services:
container_name: headplane container_name: headplane
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' - "3000:3000"
volumes: volumes:
- '/path/to/your/config.yaml:/etc/headplane/config.yaml' - "/path/to/your/config.yaml:/etc/headplane/config.yaml"
- '/path/to/data/storage:/var/lib/headplane' - "/path/to/data/storage:/var/lib/headplane"
``` ```
## Accessing Headplane ## Accessing Headplane
@@ -83,4 +87,3 @@ Limited Mode also technically supports
[Single Sign-On (SSO) authentication](../features/sso.md), but some parts of it [Single Sign-On (SSO) authentication](../features/sso.md), but some parts of it
may not work as expected. For a full-featured experience with SSO, please use may not work as expected. For a full-featured experience with SSO, please use
one of the other installation methods. one of the other installation methods.
+6 -6
View File
@@ -1,8 +1,8 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
dialect: 'sqlite', dialect: "sqlite",
schema: './app/server/db/schema.ts', schema: "./app/server/db/schema.ts",
dbCredentials: { dbCredentials: {
url: 'file:test/hp_persist.db', url: "file:test/hp_persist.db",
}, },
}); });
+23 -23
View File
@@ -1,34 +1,34 @@
import fs from "node:fs"; import fs from "node:fs";
function renderOptions(options) { function renderOptions(options) {
const blocks = Object.keys(options).map((key) => { const blocks = Object.keys(options).map((key) => {
const opt = options[key]; const opt = options[key];
const name = key.split(".").slice(2).join("."); const name = key.split(".").slice(2).join(".");
const lines = []; const lines = [];
lines.push(`## ${name}`); lines.push(`## ${name}`);
lines.push(`*Description:* ${opt.description}\n`); lines.push(`*Description:* ${opt.description}\n`);
lines.push(`*Type:* ${opt.type}\n`); lines.push(`*Type:* ${opt.type}\n`);
if (opt.default) { if (opt.default) {
lines.push(`*Default:* \`${opt.default.text}\`\n`); lines.push(`*Default:* \`${opt.default.text}\`\n`);
} }
if (opt.example) { if (opt.example) {
lines.push(`*Example:* \`${opt.example.text}\`\n`); lines.push(`*Example:* \`${opt.example.text}\`\n`);
} }
return lines.join("\n"); return lines.join("\n");
}); });
return [ return [
`# NixOS module options `# NixOS module options
| |
|All options must be under \`services.headplane\`. |All options must be under \`services.headplane\`.
| |
|For example: \`settings.headscale.config_path\` becomes \`services.headplane.settings.headscale.config_path\`.` |For example: \`settings.headscale.config_path\` becomes \`services.headplane.settings.headscale.config_path\`.`
.split("|") .split("|")
.map((s) => s.replace(/\n\s+/g, "")) .map((s) => s.replace(/\n\s+/g, ""))
.join("\n"), .join("\n"),
] ]
.concat(blocks) .concat(blocks)
.join("\n\n"); .join("\n\n");
} }
const filename = process.argv[2]; const filename = process.argv[2];
+2 -2
View File
@@ -1,8 +1,8 @@
import type { OpenAPIV2 } from "openapi-types";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { cwd } from "node:process"; import { cwd } from "node:process";
import type { OpenAPIV2 } from "openapi-types";
import { request } from "undici"; import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher"; import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
+39 -44
View File
@@ -1,73 +1,68 @@
import hashes from '~/openapi-operation-hashes.json'; import hashes from "~/openapi-operation-hashes.json";
import { import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api";
createHeadscaleInterface,
type HeadscaleApiInterface, import { type HeadscaleEnv, startHeadscale } from "./start-headscale";
} from '~/server/headscale/api'; import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
import { type HeadscaleEnv, startHeadscale } from './start-headscale';
import { startTailscaleNode, TailscaleNodeEnv } from './start-tailscale';
export type Version = keyof typeof hashes; export type Version = keyof typeof hashes;
export const HS_VERSIONS = Object.keys(hashes) as Version[]; export const HS_VERSIONS = Object.keys(hashes) as Version[];
interface VersionStateEntry { interface VersionStateEntry {
env: HeadscaleEnv; env: HeadscaleEnv;
tailscaleNode: TailscaleNodeEnv; tailscaleNode: TailscaleNodeEnv;
bootstrap: HeadscaleApiInterface; bootstrap: HeadscaleApiInterface;
} }
const versionState = new Map<Version, VersionStateEntry>(); const versionState = new Map<Version, VersionStateEntry>();
async function ensureVersion(version: Version) { async function ensureVersion(version: Version) {
if (versionState.has(version)) { if (versionState.has(version)) {
return versionState.get(version)!; return versionState.get(version)!;
} }
const env = await startHeadscale(version); const env = await startHeadscale(version);
const tailscaleNode = await startTailscaleNode( const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080));
version, const bootstrap = await createHeadscaleInterface(env.apiUrl);
env.container.getMappedPort(8080),
);
const bootstrap = await createHeadscaleInterface(env.apiUrl);
const entry = { env, tailscaleNode, bootstrap }; const entry = { env, tailscaleNode, bootstrap };
versionState.set(version, entry); versionState.set(version, entry);
return entry; return entry;
} }
export async function getBootstrapClient(version: Version) { export async function getBootstrapClient(version: Version) {
const { bootstrap } = await ensureVersion(version); const { bootstrap } = await ensureVersion(version);
return bootstrap; return bootstrap;
} }
export async function getRuntimeClient(version: Version) { export async function getRuntimeClient(version: Version) {
const { env, bootstrap } = await ensureVersion(version); const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey); return bootstrap.getRuntimeClient(env.apiKey);
} }
export async function getIsAtLeast(version: Version) { export async function getIsAtLeast(version: Version) {
const { env, bootstrap } = await ensureVersion(version); const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast; return bootstrap.clientHelpers.isAtleast;
} }
export async function getNode(version: Version) { export async function getNode(version: Version) {
const { tailscaleNode } = await ensureVersion(version); const { tailscaleNode } = await ensureVersion(version);
return { return {
authCode: tailscaleNode.authCode, authCode: tailscaleNode.authCode,
nodeName: tailscaleNode.nodeName, nodeName: tailscaleNode.nodeName,
}; };
} }
export async function stopAllVersions() { export async function stopAllVersions() {
for (const { env, tailscaleNode } of versionState.values()) { for (const { env, tailscaleNode } of versionState.values()) {
await env.container.stop({ await env.container.stop({
remove: true, remove: true,
removeVolumes: true, removeVolumes: true,
}); });
await tailscaleNode.container.stop({ await tailscaleNode.container.stop({
remove: true, remove: true,
removeVolumes: true, removeVolumes: true,
}); });
} }
versionState.clear(); versionState.clear();
} }
+64 -67
View File
@@ -1,85 +1,82 @@
import { createInterface } from 'node:readline'; import { createInterface } from "node:readline";
import tc from 'testcontainers';
import hashes from '~/openapi-operation-hashes.json'; import tc from "testcontainers";
import hashes from "~/openapi-operation-hashes.json";
export type Version = keyof typeof hashes; export type Version = keyof typeof hashes;
export interface TailscaleNodeEnv { export interface TailscaleNodeEnv {
container: tc.StartedTestContainer; container: tc.StartedTestContainer;
authCode: string; authCode: string;
nodeName: string; nodeName: string;
} }
export async function startTailscaleNode( export async function startTailscaleNode(
version: Version, version: Version,
headscalePort: number, headscalePort: number,
): Promise<TailscaleNodeEnv> { ): Promise<TailscaleNodeEnv> {
let resolveAuthCode!: (code: string) => void; let resolveAuthCode!: (code: string) => void;
let rejectAuthCode!: (err: Error) => void; let rejectAuthCode!: (err: Error) => void;
let authCodeResolved = false; let authCodeResolved = false;
const authCodePromise = new Promise<string>((resolve, reject) => { const authCodePromise = new Promise<string>((resolve, reject) => {
resolveAuthCode = resolve; resolveAuthCode = resolve;
rejectAuthCode = reject; rejectAuthCode = reject;
}); });
const nodeName = `test-node-${version.replace(/\./g, '-')}`; const nodeName = `test-node-${version.replace(/\./g, "-")}`;
const prefix = `http://localhost:8080/register/`; const prefix = `http://localhost:8080/register/`;
const container = await new tc.GenericContainer('tailscale/tailscale:latest') const container = await new tc.GenericContainer("tailscale/tailscale:latest")
.withNetworkMode('host') .withNetworkMode("host")
.withEnvironment({ .withEnvironment({
TS_STATE_DIR: '/tailscale-state', TS_STATE_DIR: "/tailscale-state",
TS_EXTRA_ARGS: [ TS_EXTRA_ARGS: [
`--login-server=http://localhost:${headscalePort}`, `--login-server=http://localhost:${headscalePort}`,
`--hostname=${nodeName}`, `--hostname=${nodeName}`,
'--accept-dns=false', "--accept-dns=false",
'--accept-routes=false', "--accept-routes=false",
].join(' '), ].join(" "),
}) })
.withLogConsumer((stream) => { .withLogConsumer((stream) => {
const rl = createInterface({ input: stream }); const rl = createInterface({ input: stream });
rl.on('line', (line: string) => { rl.on("line", (line: string) => {
if (authCodeResolved) return; if (authCodeResolved) return;
const idx = line.indexOf(prefix); const idx = line.indexOf(prefix);
if (idx === -1) return; if (idx === -1) return;
const after = line.slice(idx + prefix.length).trim(); const after = line.slice(idx + prefix.length).trim();
if (!after) return; if (!after) return;
const token = after.split(/\s+/)[0]; const token = after.split(/\s+/)[0];
if (!token) return; if (!token) return;
authCodeResolved = true; authCodeResolved = true;
resolveAuthCode(token); resolveAuthCode(token);
rl.close(); rl.close();
}); });
rl.on('close', () => { rl.on("close", () => {
if (!authCodeResolved) { if (!authCodeResolved) {
rejectAuthCode( rejectAuthCode(
new Error( new Error("Tailscale container log stream closed before auth code was found"),
'Tailscale container log stream closed before auth code was found', );
), }
); });
} })
}); .withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
}) .start();
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
.start();
const authCode = await Promise.race<string>([ const authCode = await Promise.race<string>([
authCodePromise, authCodePromise,
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => () => reject(new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`)),
reject( 25_000,
new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`), ),
), ),
25_000, ]);
),
),
]);
return { container, authCode, nodeName }; return { container, authCode, nodeName };
} }
+5 -5
View File
@@ -1,11 +1,11 @@
import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from './env'; import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from "./env";
export async function setup() { export async function setup() {
for (const version of HS_VERSIONS) { for (const version of HS_VERSIONS) {
await getBootstrapClient(version); await getBootstrapClient(version);
} }
} }
export async function teardown() { export async function teardown() {
await stopAllVersions(); await stopAllVersions();
} }
-1
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { PartialHeadplaneConfigWithPaths } from "~/server/config/config-schema"; import type { PartialHeadplaneConfigWithPaths } from "~/server/config/config-schema";
import { ConfigError } from "~/server/config/error"; import { ConfigError } from "~/server/config/error";
import { loadConfigKeyPaths } from "~/server/config/load"; import { loadConfigKeyPaths } from "~/server/config/load";
+26 -26
View File
@@ -1,40 +1,40 @@
import { vi } from 'vitest'; import { vi } from "vitest";
const fakeFs = new Map<string, string>(); const fakeFs = new Map<string, string>();
export function createFakeFile(path: string, content: string) { export function createFakeFile(path: string, content: string) {
fakeFs.set(path, content); fakeFs.set(path, content);
} }
export function clearFakeFiles() { export function clearFakeFiles() {
fakeFs.clear(); fakeFs.clear();
} }
// @ts-expect-error: I have no clue why vitest's types are wrong here // @ts-expect-error: I have no clue why vitest's types are wrong here
vi.mock(import('node:fs/promises'), async (importOrig) => { vi.mock(import("node:fs/promises"), async (importOrig) => {
const orig = await importOrig(); const orig = await importOrig();
return { return {
...orig, ...orig,
readFile: (path, options) => { readFile: (path, options) => {
const p = path.toString(); const p = path.toString();
if (fakeFs.has(p)) { if (fakeFs.has(p)) {
const content = fakeFs.get(p)!; const content = fakeFs.get(p)!;
if (typeof options === 'string' || options?.encoding) { if (typeof options === "string" || options?.encoding) {
return Promise.resolve(content); return Promise.resolve(content);
} }
return Promise.resolve(Buffer.from(content)); return Promise.resolve(Buffer.from(content));
} }
return orig.readFile.call(this, path, options); return orig.readFile.call(this, path, options);
}, },
access: (path, mode) => { access: (path, mode) => {
const p = path.toString(); const p = path.toString();
if (fakeFs.has(p)) { if (fakeFs.has(p)) {
return Promise.resolve(); return Promise.resolve();
} }
return orig.access.call(this, path, mode); return orig.access.call(this, path, mode);
}, },
}; };
}); });