diff --git a/README.md b/README.md index cb12ae0..4d1e659 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Headplane + > A feature-complete web UI for [Headscale](https://headscale.net) @@ -32,14 +33,17 @@ These are some of the features that Headplane offers: - Configurability for Headscale's settings ## Deployment + Refer to the [website](https://headplane.net) for detailed installation instructions. ## Versioning + 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 PR is opened and actively in testing. ## Contributing + 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 refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info. diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 9023e70..c995130 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,12 +1,12 @@ -import { StrictMode, startTransition } from 'react'; -import { hydrateRoot } from 'react-dom/client'; -import { HydratedRouter } from 'react-router/dom'; +import { StrictMode, startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; startTransition(() => { - hydrateRoot( - document, - - - , - ); + hydrateRoot( + document, + + + , + ); }); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 89102f6..3f8fb7a 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,10 +1,10 @@ -import type { RenderToPipeableStreamOptions } from "react-dom/server"; -import type { AppLoadContext, EntryContext } from "react-router"; +import { PassThrough } from "node:stream"; import { createReadableStreamFromReadable } from "@react-router/node"; import { isbot } from "isbot"; -import { PassThrough } from "node:stream"; +import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; +import type { AppLoadContext, EntryContext } from "react-router"; import { ServerRouter } from "react-router"; export const streamTimeout = 5_000; diff --git a/app/routes/util/healthz.ts b/app/routes/util/healthz.ts index 6e95493..6cc6596 100644 --- a/app/routes/util/healthz.ts +++ b/app/routes/util/healthz.ts @@ -1,14 +1,14 @@ -import type { Route } from './+types/healthz'; +import type { Route } from "./+types/healthz"; export async function loader({ context }: Route.LoaderArgs) { - // Use a fake API key for healthcheck - const api = context.hsApi.getRuntimeClient('fake-api-key'); - const healthy = await api.isHealthy(); + // Use a fake API key for healthcheck + const api = context.hsApi.getRuntimeClient("fake-api-key"); + const healthy = await api.isHealthy(); - return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), { - status: healthy ? 200 : 500, - headers: { - 'Content-Type': 'application/json', - }, - }); + return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), { + status: healthy ? 200 : 500, + headers: { + "Content-Type": "application/json", + }, + }); } diff --git a/app/routes/util/info.ts b/app/routes/util/info.ts index d2abed9..19e2f17 100644 --- a/app/routes/util/info.ts +++ b/app/routes/util/info.ts @@ -1,59 +1,61 @@ -import { versions } from 'node:process'; -import { data } from 'react-router'; -import type { Route } from './+types/info'; +import { versions } from "node:process"; + +import { data } from "react-router"; + +import type { Route } from "./+types/info"; export async function loader({ request, context }: Route.LoaderArgs) { - if (context.config.server.info_secret == null) { - throw data( - { - status: 'Forbidden', - }, - 403, - ); - } + if (context.config.server.info_secret == null) { + throw data( + { + status: "Forbidden", + }, + 403, + ); + } - const bearer = request.headers.get('Authorization') ?? ''; - if (!bearer.startsWith('Bearer ')) { - throw data( - { - status: 'Unauthorized', - }, - 401, - ); - } + const bearer = request.headers.get("Authorization") ?? ""; + if (!bearer.startsWith("Bearer ")) { + throw data( + { + status: "Unauthorized", + }, + 401, + ); + } - const token = bearer.slice('Bearer '.length).trim(); - if (token !== context.config.server.info_secret) { - throw data( - { - status: 'Forbidden', - }, - 403, - ); - } + const token = bearer.slice("Bearer ".length).trim(); + if (token !== context.config.server.info_secret) { + throw data( + { + status: "Forbidden", + }, + 403, + ); + } - // Use a fake API key for healthcheck - const api = context.hsApi.getRuntimeClient('fake-api-key'); - const healthy = await api.isHealthy(); + // Use a fake API key for healthcheck + const api = context.hsApi.getRuntimeClient("fake-api-key"); + const healthy = await api.isHealthy(); - const body = { - status: healthy ? 'healthy' : 'unhealthy', - headplane_version: __VERSION__, - headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown', - internal_versions: { - node: versions.node, - v8: versions.v8, - uv: versions.uv, - zlib: versions.zlib, - openssl: versions.openssl, - libc: versions.libc, - }, - }; + const body = { + status: healthy ? "healthy" : "unhealthy", + headplane_version: __VERSION__, + headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown", + internal_versions: { + node: versions.node, + v8: versions.v8, + uv: versions.uv, + zlib: versions.zlib, + openssl: versions.openssl, + libc: versions.libc, + }, + }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); } diff --git a/app/routes/util/redirect.ts b/app/routes/util/redirect.ts index beb7acc..cef719e 100644 --- a/app/routes/util/redirect.ts +++ b/app/routes/util/redirect.ts @@ -1,5 +1,5 @@ -import { redirect } from 'react-router'; +import { redirect } from "react-router"; export async function loader() { - return redirect('/machines'); + return redirect("/machines"); } diff --git a/app/server/config/error.ts b/app/server/config/error.ts index 02f92c8..1a88778 100644 --- a/app/server/config/error.ts +++ b/app/server/config/error.ts @@ -1,74 +1,72 @@ interface ErrorCodes { - CONFLICTING_SECRET_PATH_FIELD: { - fieldName: string; - }; + CONFLICTING_SECRET_PATH_FIELD: { + fieldName: string; + }; - INVALID_REQUIRED_FIELDS: { - messages: string[]; - }; + INVALID_REQUIRED_FIELDS: { + messages: string[]; + }; - MISSING_INTERPOLATION_VARIABLE: { - pathKey: string; - variableName: string; - }; + MISSING_INTERPOLATION_VARIABLE: { + pathKey: string; + variableName: string; + }; - MISSING_SECRET_FILE: { - pathKey: string; - filePath: string; - }; + MISSING_SECRET_FILE: { + pathKey: string; + filePath: string; + }; } const translationsWithVars: { - [K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string; + [K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string; } = { - CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) => - `Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`, - INVALID_REQUIRED_FIELDS: ({ messages }) => - `The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`, - MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) => - `Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`, + CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) => + `Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`, + INVALID_REQUIRED_FIELDS: ({ messages }) => + `The configuration is missing required fields or has invalid values:\n- ${messages.join("\n- ")}`, + MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) => + `Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`, - 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.`, + 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.`, } as const; /** * Custom error class for configuration-related errors. */ export class ConfigError extends Error { - /** - * The error code representing the type of configuration error. - */ - code: keyof ErrorCodes; + /** + * The error code representing the type of configuration error. + */ + code: keyof ErrorCodes; - /** - * Creates a new ConfigError instance. - * - * @param code The error code - * @param vars The variables to interpolate into the error message - */ - constructor(code: keyof ErrorCodes, vars: unknown) { - super( - translationsWithVars[code]( - vars as (typeof translationsWithVars)[typeof code] extends ( - vars: infer U, - ) => string - ? U - : never, - ), - ); - this.code = code; - this.name = 'ConfigError'; - } + /** + * Creates a new ConfigError instance. + * + * @param code The error code + * @param vars The variables to interpolate into the error message + */ + constructor(code: keyof ErrorCodes, vars: unknown) { + super( + translationsWithVars[code]( + vars as (typeof translationsWithVars)[typeof code] extends (vars: infer U) => string + ? U + : never, + ), + ); + this.code = code; + this.name = "ConfigError"; + } - /** - * Factory method to create a ConfigError instance. - * - * @param code The error code - * @param vars The variables to interpolate into the error message - * @returns A new ConfigError instance - */ - static from(code: K, vars: ErrorCodes[K]) { - return new ConfigError(code, vars); - } + /** + * Factory method to create a ConfigError instance. + * + * @param code The error code + * @param vars The variables to interpolate into the error message + * @returns A new ConfigError instance + */ + static from(code: K, vars: ErrorCodes[K]) { + return new ConfigError(code, vars); + } } diff --git a/app/server/config/integration/abstract.ts b/app/server/config/integration/abstract.ts index b83f014..7079cfe 100644 --- a/app/server/config/integration/abstract.ts +++ b/app/server/config/integration/abstract.ts @@ -1,16 +1,16 @@ -import type { RuntimeApiClient } from '~/server/headscale/api/endpoints'; +import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; export abstract class Integration { - protected context: NonNullable; - constructor(context: T) { - if (!context) { - throw new Error('Missing integration context'); - } + protected context: NonNullable; + constructor(context: T) { + if (!context) { + throw new Error("Missing integration context"); + } - this.context = context; - } + this.context = context; + } - abstract isAvailable(): Promise | boolean; - abstract onConfigChange(client: RuntimeApiClient): Promise | void; - abstract get name(): string; + abstract isAvailable(): Promise | boolean; + abstract onConfigChange(client: RuntimeApiClient): Promise | void; + abstract get name(): string; } diff --git a/app/server/config/integration/index.ts b/app/server/config/integration/index.ts index cb64535..e6276f1 100644 --- a/app/server/config/integration/index.ts +++ b/app/server/config/integration/index.ts @@ -1,62 +1,58 @@ -import log from '~/utils/log'; -import type { HeadplaneConfig } from '../config-schema'; -import dockerIntegration from './docker'; -import kubernetesIntegration from './kubernetes'; -import procIntegration from './proc'; +import log from "~/utils/log"; -export async function loadIntegration(context: HeadplaneConfig['integration']) { - const integration = getIntegration(context); - if (!integration) { - return; - } +import type { HeadplaneConfig } from "../config-schema"; +import dockerIntegration from "./docker"; +import kubernetesIntegration from "./kubernetes"; +import procIntegration from "./proc"; - 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; - } +export async function loadIntegration(context: HeadplaneConfig["integration"]) { + const integration = getIntegration(context); + if (!integration) { + 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']) { - const docker = integration?.docker; - const k8s = integration?.kubernetes; - const proc = integration?.proc; +function getIntegration(integration: HeadplaneConfig["integration"]) { + const docker = integration?.docker; + const k8s = integration?.kubernetes; + const proc = integration?.proc; - if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) { - log.debug('config', 'No integrations enabled'); - return; - } + if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) { + log.debug("config", "No integrations enabled"); + return; + } - if (docker?.enabled && k8s?.enabled && proc?.enabled) { - log.error('config', 'Multiple integrations enabled, please pick one only'); - return; - } + if (docker?.enabled && k8s?.enabled && proc?.enabled) { + log.error("config", "Multiple integrations enabled, please pick one only"); + return; + } - if (docker?.enabled) { - log.info('config', 'Using Docker integration'); - return new dockerIntegration(integration!.docker!); - } + if (docker?.enabled) { + log.info("config", "Using Docker integration"); + return new dockerIntegration(integration!.docker!); + } - if (k8s?.enabled) { - log.info('config', 'Using Kubernetes integration'); - return new kubernetesIntegration(integration!.kubernetes!); - } + if (k8s?.enabled) { + log.info("config", "Using Kubernetes integration"); + return new kubernetesIntegration(integration!.kubernetes!); + } - if (proc?.enabled) { - log.info('config', 'Using Proc integration'); - return new procIntegration(integration!.proc!); - } + if (proc?.enabled) { + log.info("config", "Using Proc integration"); + return new procIntegration(integration!.proc!); + } } diff --git a/app/server/config/integration/kubernetes.ts b/app/server/config/integration/kubernetes.ts index fa201f7..24613cf 100644 --- a/app/server/config/integration/kubernetes.ts +++ b/app/server/config/integration/kubernetes.ts @@ -1,11 +1,11 @@ -import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"; -import { type } from "arktype"; import { readdir, readFile } from "node:fs/promises"; import { platform } from "node:os"; 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 { Integration } from "./abstract"; diff --git a/app/server/config/integration/proc-helper.ts b/app/server/config/integration/proc-helper.ts index 4432dde..0c087aa 100644 --- a/app/server/config/integration/proc-helper.ts +++ b/app/server/config/integration/proc-helper.ts @@ -4,7 +4,6 @@ import { kill } from "node:process"; import { setTimeout } from "node:timers/promises"; import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; - import log from "~/utils/log"; /** diff --git a/app/server/config/integration/proc.ts b/app/server/config/integration/proc.ts index 8c9a886..e6a7466 100644 --- a/app/server/config/integration/proc.ts +++ b/app/server/config/integration/proc.ts @@ -1,8 +1,8 @@ -import { type } from "arktype"; 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 { Integration } from "./abstract"; diff --git a/app/server/config/load.ts b/app/server/config/load.ts index 41d1cc4..91d156a 100644 --- a/app/server/config/load.ts +++ b/app/server/config/load.ts @@ -1,14 +1,17 @@ -import { access, constants, readFile } from 'node:fs/promises'; -import { type } from 'arktype'; -import { load } from 'js-yaml'; -import log from '~/utils/log'; +import { access, constants, readFile } from "node:fs/promises"; + +import { type } from "arktype"; +import { load } from "js-yaml"; + +import log from "~/utils/log"; + import { - headplaneConfig, - PartialHeadplaneConfig, - partialHeadplaneConfig, - pathSupportedKeys, -} from './config-schema'; -import { ConfigError } from './error'; + headplaneConfig, + PartialHeadplaneConfig, + partialHeadplaneConfig, + pathSupportedKeys, +} from "./config-schema"; +import { ConfigError } from "./error"; /** * 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 */ export async function loadConfig(configPathOverride?: string) { - const configPath = - configPathOverride != null - ? configPathOverride - : process.env.HEADPLANE_CONFIG_PATH != null - ? String(process.env.HEADPLANE_CONFIG_PATH) - : '/etc/headplane/config.yaml'; + const configPath = + configPathOverride != null + ? configPathOverride + : process.env.HEADPLANE_CONFIG_PATH != null + ? String(process.env.HEADPLANE_CONFIG_PATH) + : "/etc/headplane/config.yaml"; - const fileConfig = await loadConfigFile(configPath); - const envConfig = await loadConfigEnv(); + const fileConfig = await loadConfigFile(configPath); + const envConfig = await loadConfigEnv(); - const combinedConfig = deepMerge(fileConfig, envConfig); - await loadConfigKeyPaths(combinedConfig); + const combinedConfig = deepMerge(fileConfig, envConfig); + await loadConfigKeyPaths(combinedConfig); - const finalConfig = headplaneConfig(combinedConfig); - if (finalConfig instanceof type.errors) { - throw ConfigError.from('INVALID_REQUIRED_FIELDS', { - messages: finalConfig.map((e) => e.toString()), - }); - } + const finalConfig = headplaneConfig(combinedConfig); + if (finalConfig instanceof type.errors) { + throw ConfigError.from("INVALID_REQUIRED_FIELDS", { + 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 */ export async function loadConfigFile(path: string) { - try { - await access(path, constants.R_OK); - } catch { - log.info('config', 'Could not access config file at path: %s', path); - return; - } + try { + await access(path, constants.R_OK); + } catch { + log.info("config", "Could not access config file at path: %s", path); + return; + } - const rawBuffer = await readFile(path, 'utf8'); - const rawConfig = load(rawBuffer); - const config = partialHeadplaneConfig(rawConfig); - if (config instanceof type.errors) { - throw ConfigError.from('INVALID_REQUIRED_FIELDS', { - messages: config.map((e) => e.toString()), - }); - } + const rawBuffer = await readFile(path, "utf8"); + const rawConfig = load(rawBuffer); + const config = partialHeadplaneConfig(rawConfig); + if (config instanceof type.errors) { + throw ConfigError.from("INVALID_REQUIRED_FIELDS", { + 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 */ export async function loadConfigEnv() { - if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) { - log.warn( - 'config', - 'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions', - ); - log.warn( - 'config', - 'Environment variables are always loaded and `.env` files are no longer supported', - ); - } + if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) { + log.warn( + "config", + "HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions", + ); + log.warn( + "config", + "Environment variables are always loaded and `.env` files are no longer supported", + ); + } - const rawConfig: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value == null || !key.startsWith('HEADPLANE_')) { - continue; - } + const rawConfig: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value == null || !key.startsWith("HEADPLANE_")) { + continue; + } - const parsedValue = parseEnvValue(value); - const configKey = key.slice('HEADPLANE_'.length).toLowerCase(); - deepSet(rawConfig, configKey.split('__'), parsedValue); - } + const parsedValue = parseEnvValue(value); + const configKey = key.slice("HEADPLANE_".length).toLowerCase(); + deepSet(rawConfig, configKey.split("__"), parsedValue); + } - const config = partialHeadplaneConfig(rawConfig); - if (config instanceof type.errors) { - throw ConfigError.from('INVALID_REQUIRED_FIELDS', { - messages: config.map((e) => e.toString()), - }); - } + const config = partialHeadplaneConfig(rawConfig); + if (config instanceof type.errors) { + throw ConfigError.from("INVALID_REQUIRED_FIELDS", { + 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 */ function deepMerge(...objects: (T | undefined)[]): T { - const result: { [key: string]: unknown } = {}; - for (const obj of objects.filter((o) => o != null)) { - for (const [key, value] of Object.entries( - obj as { - [key: string]: unknown; - }, - )) { - if (value != null && typeof value === 'object' && !Array.isArray(value)) { - if ( - result[key] == null || - typeof result[key] !== 'object' || - Array.isArray(result[key]) - ) { - result[key] = {}; - } - result[key] = deepMerge(result[key], value); - } else { - result[key] = value; - } - } - } + const result: { [key: string]: unknown } = {}; + for (const obj of objects.filter((o) => o != null)) { + for (const [key, value] of Object.entries( + obj as { + [key: string]: unknown; + }, + )) { + if (value != null && typeof value === "object" && !Array.isArray(value)) { + if (result[key] == null || typeof result[key] !== "object" || Array.isArray(result[key])) { + result[key] = {}; + } + result[key] = deepMerge(result[key], value); + } else { + result[key] = value; + } + } + } - return result as T; + return result as T; } /** @@ -158,22 +157,18 @@ function deepMerge(...objects: (T | undefined)[]): T { * @param path An array of keys representing the path to set * @param value The value to set at the specified path */ -function deepSet( - obj: { [key: string]: unknown }, - path: string[], - value: unknown, -): void { - let current = obj; - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; - if (current[key] == null || typeof current[key] !== 'object') { - current[key] = {}; - } +function deepSet(obj: { [key: string]: unknown }, path: string[], value: unknown): void { + let current = obj; + 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 */ function parseEnvValue(value: string): unknown { - const v = value.trim().toLowerCase(); - if (v === 'true') return true; - if (v === 'false') return false; - if (v === 'null') return null; - if (v === 'undefined') return undefined; + const v = value.trim().toLowerCase(); + if (v === "true") return true; + if (v === "false") return false; + if (v === "null") return null; + if (v === "undefined") return undefined; - if (/^-?\d+(\.\d+)?$/.test(v)) { - const num = Number(v); - if (!Number.isNaN(num)) return num; - } + if (/^-?\d+(\.\d+)?$/.test(v)) { + const num = Number(v); + 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 */ export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) { - for (const key of pathSupportedKeys) { - const pathKey = `${key}_path`; - const pathValue = deepGet(partial, pathKey.split('.')); - const existing = deepGet(partial, key.split('.')); + for (const key of pathSupportedKeys) { + const pathKey = `${key}_path`; + const pathValue = deepGet(partial, pathKey.split(".")); + const existing = deepGet(partial, key.split(".")); - if (pathValue == null || typeof pathValue !== 'string') { - continue; - } + if (pathValue == null || typeof pathValue !== "string") { + continue; + } - if (existing != null) { - throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', { - fieldName: key, - }); - } + if (existing != null) { + throw ConfigError.from("CONFLICTING_SECRET_PATH_FIELD", { + fieldName: key, + }); + } - const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => { - const value = process.env[variableName]; - if (value === undefined) { - throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', { - pathKey: `${key}_path`, - variableName: variableName, - }); - } + const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => { + const value = process.env[variableName]; + if (value === undefined) { + throw ConfigError.from("MISSING_INTERPOLATION_VARIABLE", { + pathKey: `${key}_path`, + variableName: variableName, + }); + } - return value; - }); + return value; + }); - try { - const fileContent = await readFile(realPath, 'utf8'); - deepSet(partial, key.split('.'), fileContent.trim().normalize()); - } catch { - throw ConfigError.from('MISSING_SECRET_FILE', { - pathKey: `${key}_path`, - filePath: realPath, - }); - } - } + try { + const fileContent = await readFile(realPath, "utf8"); + deepSet(partial, key.split("."), fileContent.trim().normalize()); + } catch { + throw ConfigError.from("MISSING_SECRET_FILE", { + pathKey: `${key}_path`, + filePath: realPath, + }); + } + } } /** @@ -254,14 +249,14 @@ export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) { * @returns The value at the specified path or undefined if not found */ function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown { - let current = obj; - for (const segment of path) { - if (current == null || typeof current !== 'object') { - return undefined; - } + let current = obj; + for (const segment of path) { + if (current == null || typeof current !== "object") { + return undefined; + } - current = current[segment] as { [key: string]: unknown }; - } + current = current[segment] as { [key: string]: unknown }; + } - return current; + return current; } diff --git a/app/server/config/utils.ts b/app/server/config/utils.ts index 937a8d8..f4fe75f 100644 --- a/app/server/config/utils.ts +++ b/app/server/config/utils.ts @@ -1,9 +1,10 @@ -import type { Traversal } from 'arktype'; -import log from '~/utils/log'; +import type { Traversal } from "arktype"; + +import log from "~/utils/log"; export function deprecatedField() { - return (_: unknown, ctx: Traversal) => { - log.warn('config', `${ctx.propString} is deprecated and has no effect.`); - return true; - }; + return (_: unknown, ctx: Traversal) => { + log.warn("config", `${ctx.propString} is deprecated and has no effect.`); + return true; + }; } diff --git a/app/server/headscale/api/endpoints/api-keys.ts b/app/server/headscale/api/endpoints/api-keys.ts index 8e9090f..bad1d91 100644 --- a/app/server/headscale/api/endpoints/api-keys.ts +++ b/app/server/headscale/api/endpoints/api-keys.ts @@ -1,23 +1,20 @@ -import type { Key } from '~/types'; -import { defineApiEndpoints } from '../factory'; +import type { Key } from "~/types"; + +import { defineApiEndpoints } from "../factory"; export interface ApiKeyEndpoints { - /** - * Retrieves all API keys from the Headscale instance. - * - * @returns An array of `Key` objects representing the API keys. - */ - getApiKeys(): Promise; + /** + * Retrieves all API keys from the Headscale instance. + * + * @returns An array of `Key` objects representing the API keys. + */ + getApiKeys(): Promise; } export default defineApiEndpoints((client, apiKey) => ({ - getApiKeys: async () => { - const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>( - 'GET', - 'v1/apikey', - apiKey, - ); + getApiKeys: async () => { + const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey); - return apiKeys; - }, + return apiKeys; + }, })); diff --git a/app/server/headscale/api/endpoints/index.ts b/app/server/headscale/api/endpoints/index.ts index 32fe88f..72b18f5 100644 --- a/app/server/headscale/api/endpoints/index.ts +++ b/app/server/headscale/api/endpoints/index.ts @@ -1,56 +1,54 @@ import { - composeEndpoints, - defineApiEndpoints, - type ExtractApiEndpoints, - type UnionToIntersection, -} from '../factory'; -import type { HeadscaleApiInterface } from '../index'; -import apiKeyEndpoints from './api-keys'; -import nodeEndpoints from './nodes'; -import policyEndpoints from './policy'; -import preAuthKeyEndpoints from './pre-auth-keys'; -import userEndpoints from './users'; + composeEndpoints, + defineApiEndpoints, + type ExtractApiEndpoints, + type UnionToIntersection, +} from "../factory"; +import type { HeadscaleApiInterface } from "../index"; +import apiKeyEndpoints from "./api-keys"; +import nodeEndpoints from "./nodes"; +import policyEndpoints from "./policy"; +import preAuthKeyEndpoints from "./pre-auth-keys"; +import userEndpoints from "./users"; interface HealthcheckEndpoint { - /** - * Checks if the Headscale instance is healthy. - * - * @returns A boolean indicating if the instance is healthy. - */ - isHealthy(): Promise; + /** + * Checks if the Headscale instance is healthy. + * + * @returns A boolean indicating if the instance is healthy. + */ + isHealthy(): Promise; } -const healthcheckEndpoint = defineApiEndpoints( - (client, apiKey) => ({ - isHealthy: async () => { - try { - const res = await client.rawFetch('/health', { - method: 'GET', - headers: { - // This doesn't really matter - Authorization: `Bearer ${apiKey}`, - }, - }); +const healthcheckEndpoint = defineApiEndpoints((client, apiKey) => ({ + isHealthy: async () => { + try { + const res = await client.rawFetch("/health", { + method: "GET", + headers: { + // This doesn't really matter + Authorization: `Bearer ${apiKey}`, + }, + }); - return res.statusCode === 200; - } catch { - return false; - } - }, - }), -); + return res.statusCode === 200; + } catch { + return false; + } + }, +})); /** * A constant list of all endpoint groups. * Add new endpoint groups here. */ export const endpointSets = [ - apiKeyEndpoints, - healthcheckEndpoint, - nodeEndpoints, - policyEndpoints, - preAuthKeyEndpoints, - userEndpoints, + apiKeyEndpoints, + healthcheckEndpoint, + nodeEndpoints, + policyEndpoints, + preAuthKeyEndpoints, + userEndpoints, ] as const; /** @@ -63,7 +61,7 @@ export const endpointSets = [ * passing in different internal implementations based on the OpenAPI spec. */ 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. * @returns A fully composed runtime API client. */ -export default ( - client: HeadscaleApiInterface['clientHelpers'], - apiKey: string, -) => composeEndpoints(endpointSets, client, apiKey); +export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => + composeEndpoints(endpointSets, client, apiKey); diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index e9289e1..8fd8144 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -1,7 +1,6 @@ import type { Machine } from "~/types"; import type { HeadscaleApiInterface } from ".."; - import { defineApiEndpoints } from "../factory"; interface RawMachine extends Omit { diff --git a/app/server/headscale/api/endpoints/policy.ts b/app/server/headscale/api/endpoints/policy.ts index 28c8f37..15f267d 100644 --- a/app/server/headscale/api/endpoints/policy.ts +++ b/app/server/headscale/api/endpoints/policy.ts @@ -1,41 +1,41 @@ -import { defineApiEndpoints } from '../factory'; +import { defineApiEndpoints } from "../factory"; export interface PolicyEndpoints { - /** - * Retrieves the current ACL policy from the Headscale instance. - * - * @returns The ACL policy as a string and the date it was last updated. - */ - getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>; + /** + * Retrieves the current ACL policy from the Headscale instance. + * + * @returns The ACL policy as a string and the date it was last updated. + */ + getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>; - /** - * Sets the ACL policy for the Headscale instance. - * - * @param policy The ACL policy as a string. - * @returns The updated ACL policy as a string and the date it was last updated. - */ - setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>; + /** + * Sets the ACL policy for the Headscale instance. + * + * @param policy The ACL policy as a string. + * @returns The updated ACL policy as a string and the date it was last updated. + */ + setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>; } export default defineApiEndpoints((client, apiKey) => ({ - getPolicy: async () => { - const { policy, updatedAt } = await client.apiFetch<{ - policy: string; - updatedAt: string; - }>('GET', 'v1/policy', apiKey); + getPolicy: async () => { + const { policy, updatedAt } = await client.apiFetch<{ + policy: string; + updatedAt: string; + }>("GET", "v1/policy", apiKey); - return { - policy, - updatedAt: updatedAt !== null ? new Date(updatedAt) : null, - }; - }, + return { + policy, + updatedAt: updatedAt !== null ? new Date(updatedAt) : null, + }; + }, - setPolicy: async (policy) => { - const { policy: newPolicy, updatedAt } = await client.apiFetch<{ - policy: string; - updatedAt: string; - }>('PUT', 'v1/policy', apiKey, { policy }); + setPolicy: async (policy) => { + const { policy: newPolicy, updatedAt } = await client.apiFetch<{ + policy: string; + updatedAt: string; + }>("PUT", "v1/policy", apiKey, { policy }); - return { policy: newPolicy, updatedAt: new Date(updatedAt) }; - }, + return { policy: newPolicy, updatedAt: new Date(updatedAt) }; + }, })); diff --git a/app/server/headscale/api/endpoints/users.ts b/app/server/headscale/api/endpoints/users.ts index 8da52ca..a509e8d 100644 --- a/app/server/headscale/api/endpoints/users.ts +++ b/app/server/headscale/api/endpoints/users.ts @@ -1,88 +1,83 @@ -import type { User } from '~/types'; -import { defineApiEndpoints } from '../factory'; +import type { User } from "~/types"; + +import { defineApiEndpoints } from "../factory"; export interface UserEndpoints { - /** - * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. - * - * @param id Optional ID of the user to retrieve. - * @param name Optional name of the user to retrieve. - * @param email Optional email of the user to retrieve. - * @returns An array of `User` objects representing the users. - */ - getUsers(id?: string, name?: string, email?: string): Promise; + /** + * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. + * + * @param id Optional ID of the user to retrieve. + * @param name Optional name of the user to retrieve. + * @param email Optional email of the user to retrieve. + * @returns An array of `User` objects representing the users. + */ + getUsers(id?: string, name?: string, email?: string): Promise; - /** - * Creates a new user in the Headscale instance. - * - * @param username The username of the new user. - * @param email Optional email of the new user. - * @param displayName Optional display name of the new user. - * @param pictureUrl Optional picture URL of the new user. - * @returns A `User` object representing the newly created user. - */ - createUser( - username: string, - email?: string, - displayName?: string, - pictureUrl?: string, - ): Promise; + /** + * Creates a new user in the Headscale instance. + * + * @param username The username of the new user. + * @param email Optional email of the new user. + * @param displayName Optional display name of the new user. + * @param pictureUrl Optional picture URL of the new user. + * @returns A `User` object representing the newly created user. + */ + createUser( + username: string, + email?: string, + displayName?: string, + pictureUrl?: string, + ): Promise; - /** - * Deletes a specific user by its ID. - * - * @param id The ID of the user to delete. - */ - deleteUser(id: string): Promise; + /** + * Deletes a specific user by its ID. + * + * @param id The ID of the user to delete. + */ + deleteUser(id: string): Promise; - /** - * Renames a specific user by its ID. - * - * @param id The ID of the user to rename. - * @param newName The new name for the user. - */ - renameUser(id: string, newName: string): Promise; + /** + * Renames a specific user by its ID. + * + * @param id The ID of the user to rename. + * @param newName The new name for the user. + */ + renameUser(id: string, newName: string): Promise; } export default defineApiEndpoints((client, apiKey) => ({ - getUsers: async (id, name, email) => { - const moreThanOneFilter = - [id, name, email].filter((v) => v !== undefined).length > 1; + getUsers: async (id, name, email) => { + const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1; - if (moreThanOneFilter) { - throw new Error('Only one of id, name, or email filters can be provided'); - } + if (moreThanOneFilter) { + throw new Error("Only one of id, name, or email filters can be provided"); + } - const { users } = await client.apiFetch<{ users: User[] }>( - 'GET', - 'v1/user', - apiKey, - { id, name, email }, - ); + const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, { + id, + name, + email, + }); - return users; - }, + return users; + }, - createUser: async (username, email, displayName, pictureUrl) => { - const { user } = await client.apiFetch<{ user: User }>( - 'POST', - 'v1/user', - apiKey, - { name: username, email, displayName, pictureUrl }, - ); + createUser: async (username, email, displayName, pictureUrl) => { + const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, { + name: username, + email, + displayName, + pictureUrl, + }); - return user; - }, + return user; + }, - deleteUser: async (id) => { - await client.apiFetch('DELETE', `v1/user/${id}`, apiKey); - }, + deleteUser: async (id) => { + await client.apiFetch("DELETE", `v1/user/${id}`, apiKey); + }, - renameUser: async (oldId, newName) => { - await client.apiFetch( - 'POST', - `v1/user/${oldId}/rename/${newName}`, - apiKey, - ); - }, + renameUser: async (oldId, newName) => { + await client.apiFetch("POST", `v1/user/${oldId}/rename/${newName}`, apiKey); + }, })); diff --git a/app/server/headscale/api/error-client.ts b/app/server/headscale/api/error-client.ts index 8088871..9408ab8 100644 --- a/app/server/headscale/api/error-client.ts +++ b/app/server/headscale/api/error-client.ts @@ -1,13 +1,13 @@ -import type { HeadscaleConnectionError } from './error'; +import type { HeadscaleConnectionError } from "./error"; /** * Represents an error returned by the Headscale API. */ export interface HeadscaleAPIError { - requestUrl: `${string} ${string}`; - statusCode: number; - rawData: string; - data: Record | null; + requestUrl: `${string} ${string}`; + statusCode: number; + rawData: string; + data: Record | null; } /** @@ -16,14 +16,14 @@ export interface HeadscaleAPIError { * @returns True if the error is a HeadscaleAPIError, false otherwise. */ export function isApiError(error: unknown): error is HeadscaleAPIError { - return ( - error != null && - typeof error === 'object' && - 'requestUrl' in error && - 'statusCode' in error && - 'rawData' in error && - 'data' in error - ); + return ( + error != null && + typeof error === "object" && + "requestUrl" in error && + "statusCode" in error && + "rawData" in error && + "data" in error + ); } /** @@ -31,17 +31,15 @@ export function isApiError(error: unknown): error is HeadscaleAPIError { * @param error - The error to check. * @returns True if the error is a HeadscaleConnectionError, false otherwise. */ -export function isConnectionError( - error: unknown, -): error is HeadscaleConnectionError { - return ( - error != null && - typeof error === 'object' && - 'requestUrl' in error && - 'errorCode' in error && - 'errorMessage' in error && - 'extraData' in error - ); +export function isConnectionError(error: unknown): error is HeadscaleConnectionError { + return ( + error != null && + typeof error === "object" && + "requestUrl" in error && + "errorCode" 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. */ export function isDataUnauthorizedError(error: unknown): boolean { - return ( - error != null && - typeof error === 'object' && - 'data' in error && - typeof error.data === 'object' && - error.data != null && - 'statusCode' in error.data && - error.data.statusCode === 401 - ); + return ( + error != null && + typeof error === "object" && + "data" in error && + typeof error.data === "object" && + error.data != null && + "statusCode" in error.data && + error.data.statusCode === 401 + ); } /** @@ -72,13 +70,6 @@ export function isDataUnauthorizedError(error: unknown): boolean { * @returns True if the error is a DataWithResponseInit containing a * HeadscaleAPIError, false otherwise. */ -export function isDataWithApiError( - error: unknown, -): error is { data: HeadscaleAPIError } { - return ( - error != null && - typeof error === 'object' && - 'data' in error && - isApiError(error.data) - ); +export function isDataWithApiError(error: unknown): error is { data: HeadscaleAPIError } { + return error != null && typeof error === "object" && "data" in error && isApiError(error.data); } diff --git a/app/server/headscale/api/error.ts b/app/server/headscale/api/error.ts index a58cb30..77144e8 100644 --- a/app/server/headscale/api/error.ts +++ b/app/server/headscale/api/error.ts @@ -1,4 +1,4 @@ -import { errors } from 'undici'; +import { errors } from "undici"; /** * 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 */ function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException { - return ( - error != null && - typeof error === 'object' && - 'code' in error && - 'errno' in error - ); + return error != null && typeof error === "object" && "code" in error && "errno" in error; } /** * A friendly error representation for Headscale connection issues. */ export interface HeadscaleConnectionError { - requestUrl: string; - errorCode: string; - errorMessage: string; - extraData: Record | null; + requestUrl: string; + errorCode: string; + errorMessage: string; + extraData: Record | null; } /** @@ -33,40 +28,40 @@ export interface HeadscaleConnectionError { * @returns A friendly HeadscaleAPIError. */ export function undiciToFriendlyError( - error: unknown, - requestUrl: string, + error: unknown, + requestUrl: string, ): HeadscaleConnectionError { - // MARK: Do we need to go deeper into causes here? - if (error instanceof AggregateError) { - error = error.errors[0]; - } + // MARK: Do we need to go deeper into causes here? + if (error instanceof AggregateError) { + error = error.errors[0]; + } - if (error instanceof errors.UndiciError) { - return { - requestUrl, - errorCode: error.code, - errorMessage: error.message, - extraData: null, - }; - } + if (error instanceof errors.UndiciError) { + return { + requestUrl, + errorCode: error.code, + errorMessage: error.message, + extraData: null, + }; + } - if (isNodeNetworkError(error)) { - return { - requestUrl, - errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR', - errorMessage: error.message, - extraData: { - syscall: error.syscall, - path: error.path, - errno: error.errno, - }, - }; - } + if (isNodeNetworkError(error)) { + return { + requestUrl, + errorCode: error.code ?? "UNKNOWN_NODE_NETWORK_ERROR", + errorMessage: error.message, + extraData: { + syscall: error.syscall, + path: error.path, + errno: error.errno, + }, + }; + } - return { - requestUrl, - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'An unknown error occured', - extraData: null, - }; + return { + requestUrl, + errorCode: "UNKNOWN_ERROR", + errorMessage: "An unknown error occured", + extraData: null, + }; } diff --git a/app/server/headscale/api/factory.ts b/app/server/headscale/api/factory.ts index adc7a1f..ba83b46 100644 --- a/app/server/headscale/api/factory.ts +++ b/app/server/headscale/api/factory.ts @@ -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. @@ -8,38 +8,31 @@ import type { HeadscaleApiInterface } from '../api'; */ export interface EndpointFactory { - __type?: T; - (client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T; + __type?: T; + (client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T; } export function defineApiEndpoints( - factories: ( - client: HeadscaleApiInterface['clientHelpers'], - apiKey: string, - ) => T, + factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T, ): EndpointFactory { - return factories; + return factories; } -export type ExtractApiEndpoints = F extends EndpointFactory - ? T - : never; -export type UnionToIntersection = ( - U extends any - ? (k: U) => void - : never -) extends (k: infer I) => void - ? I - : never; +export type ExtractApiEndpoints = F extends EndpointFactory ? T : never; +export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; /** * Compose multiple endpoint sets into a single typed runtime client */ export function composeEndpoints[]>( - factories: T, - clientHelpers: any, - apiKey: string, + factories: T, + clientHelpers: any, + apiKey: string, ): UnionToIntersection> { - const instances = factories.map((f) => f(clientHelpers, apiKey)); - return Object.assign({}, ...instances) as any; + const instances = factories.map((f) => f(clientHelpers, apiKey)); + return Object.assign({}, ...instances) as any; } diff --git a/app/server/headscale/api/hasher.ts b/app/server/headscale/api/hasher.ts index 5c553fe..953f602 100644 --- a/app/server/headscale/api/hasher.ts +++ b/app/server/headscale/api/hasher.ts @@ -1,12 +1,13 @@ -import { createHash } from 'node:crypto'; -import { dereference } from '@readme/openapi-parser'; -import { OpenAPIV2 } from 'openapi-types'; +import { createHash } from "node:crypto"; + +import { dereference } from "@readme/openapi-parser"; +import { OpenAPIV2 } from "openapi-types"; /** * A map of operation IDs to their hashes. */ export interface DocumentHash { - [operationId: string]: string; + [operationId: string]: string; } /** @@ -17,36 +18,34 @@ export interface DocumentHash { * @param doc The OpenAPI v2 document to hash. * @returns A map of operation IDs to their hashes. */ -export async function hashOpenApiDocument( - doc: OpenAPIV2.Document, -): Promise { - const spec = await dereference(doc); - const hashes: DocumentHash = {}; - const seen = new Set(); +export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise { + const spec = await dereference(doc); + const hashes: DocumentHash = {}; + const seen = new Set(); - for (const [path, item] of Object.entries(spec.paths)) { - for (const [method, operation] of Object.entries(item)) { - if (typeof operation !== 'object') { - continue; - } + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (typeof operation !== "object") { + continue; + } - const { parameters, responses } = operation as OpenAPIV2.OperationObject; - const raw = JSON.stringify( - { - path, - method: method.toUpperCase(), - parameters, - responses, - }, - Object.keys({ path, method, parameters, responses }).sort(), - ); + const { parameters, responses } = operation as OpenAPIV2.OperationObject; + const raw = JSON.stringify( + { + path, + method: method.toUpperCase(), + parameters, + responses, + }, + Object.keys({ path, method, parameters, responses }).sort(), + ); - const hash = createHash('md5').update(raw).digest('hex').slice(0, 16); - const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; - seen.add(final); - hashes[`${method.toUpperCase()} ${path}`] = final; - } - } + const hash = createHash("md5").update(raw).digest("hex").slice(0, 16); + const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; + seen.add(final); + hashes[`${method.toUpperCase()} ${path}`] = final; + } + } - return hashes; + return hashes; } diff --git a/app/server/headscale/api/version.ts b/app/server/headscale/api/version.ts index 6fe130e..167cade 100644 --- a/app/server/headscale/api/version.ts +++ b/app/server/headscale/api/version.ts @@ -1,6 +1,6 @@ -import canonicals from '~/openapi-canonical-families.json'; -import hashes from '~/openapi-operation-hashes.json'; -import log from '~/utils/log'; +import canonicals from "~/openapi-canonical-families.json"; +import hashes from "~/openapi-operation-hashes.json"; +import log from "~/utils/log"; /** * 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. * @returns The detected API version. */ -export function detectApiVersion( - observed: Record | null, -): Version { - if (!observed) { - const latest = VERSIONS.at(-1)!; - log.warn( - 'api', - 'No operation hashes observed, defaulting to version %s', - latest, - ); - return latest; - } +export function detectApiVersion(observed: Record | null): Version { + if (!observed) { + const latest = VERSIONS.at(-1)!; + log.warn("api", "No operation hashes observed, defaulting to version %s", latest); + return latest; + } - let bestVersion: Version | null = null; - let bestScore = -1; + let bestVersion: Version | null = null; + let bestScore = -1; - for (const [version, known] of Object.entries(hashes) as [ - Version, - Record, - ][]) { - let score = 0; - for (const [op, hash] of Object.entries(observed)) { - if (known[op] === hash) score++; - } - if (score > bestScore) { - bestVersion = version; - bestScore = score; - } - } + for (const [version, known] of Object.entries(hashes) as [Version, Record][]) { + let score = 0; + for (const [op, hash] of Object.entries(observed)) { + if (known[op] === hash) score++; + } + if (score > bestScore) { + bestVersion = version; + bestScore = score; + } + } - if (!bestVersion || bestScore === 0) { - const latest = VERSIONS.at(-1)!; - log.warn( - 'api', - 'Could not determine API version, defaulting to %s', - latest, - ); - return latest; - } + if (!bestVersion || bestScore === 0) { + const latest = VERSIONS.at(-1)!; + log.warn("api", "Could not determine API version, defaulting to %s", latest); + return latest; + } - if (bestScore < Object.keys(observed).length) { - log.warn( - 'api', - 'Partial version match: %d/%d endpoints for version %s', - bestScore, - Object.keys(observed).length, - bestVersion, - ); - } + if (bestScore < Object.keys(observed).length) { + log.warn( + "api", + "Partial version match: %d/%d endpoints for version %s", + bestScore, + Object.keys(observed).length, + bestVersion, + ); + } - const canonical = Object.entries(canonicals).find(([_, family]) => - family.includes(bestVersion), - )?.[0] as Version | undefined; + const canonical = Object.entries(canonicals).find(([_, family]) => + family.includes(bestVersion), + )?.[0] as Version | undefined; - if (!canonical) { - log.warn( - 'api', - 'Could not canonicalize detected version %s, using as-is', - bestVersion, - ); + if (!canonical) { + log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion); - return bestVersion; - } + return bestVersion; + } - if (canonical !== bestVersion) { - log.info( - 'api', - 'Canonicalizing detected version %s → %s (same schema)', - bestVersion, - canonical, - ); - } + if (canonical !== bestVersion) { + log.info( + "api", + "Canonicalizing detected version %s → %s (same schema)", + bestVersion, + canonical, + ); + } - return canonical; + return canonical; } /** @@ -99,5 +82,5 @@ export function detectApiVersion( * @returns True if current is at least baseline, false otherwise. */ export function isAtLeast(current: Version, baseline: Version) { - return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline); + return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline); } diff --git a/app/server/headscale/config-dns.ts b/app/server/headscale/config-dns.ts index 26c42e9..66f087c 100644 --- a/app/server/headscale/config-dns.ts +++ b/app/server/headscale/config-dns.ts @@ -1,11 +1,12 @@ -import { access, constants, readFile, writeFile } from 'node:fs/promises'; -import { setTimeout } from 'node:timers/promises'; -import log from '~/utils/log'; +import { access, constants, readFile, writeFile } from "node:fs/promises"; +import { setTimeout } from "node:timers/promises"; + +import log from "~/utils/log"; export interface DNSRecord { - type: 'A' | 'AAAA' | (string & {}); - name: string; - value: string; + type: "A" | "AAAA" | (string & {}); + name: string; + value: string; } // 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, // but are passed through to here if the extra file is being used. export class HeadscaleDNSConfig { - private records: DNSRecord[]; - private access: 'rw' | 'ro' | 'no'; - private path?: string; - private writeLock = false; + private records: DNSRecord[]; + private access: "rw" | "ro" | "no"; + private path?: string; + private writeLock = false; - constructor( - access: 'rw' | 'ro' | 'no', - records?: DNSRecord[], - path?: string, - ) { - this.access = access; - this.records = records ?? []; - this.path = path; - } + constructor(access: "rw" | "ro" | "no", records?: DNSRecord[], path?: string) { + this.access = access; + this.records = records ?? []; + this.path = path; + } - readable() { - return this.access !== 'no'; - } + readable() { + return this.access !== "no"; + } - writable() { - return this.access === 'rw'; - } + writable() { + return this.access === "rw"; + } - get r() { - return this.records; - } + get r() { + return this.records; + } - async patch(records: DNSRecord[]) { - if (!this.path || !this.readable() || !this.writable()) { - return; - } + async patch(records: DNSRecord[]) { + if (!this.path || !this.readable() || !this.writable()) { + return; + } - this.records = records; - log.debug( - 'config', - 'Patching DNS records (%d -> %d)', - this.records.length, - records.length, - ); + this.records = records; + log.debug("config", "Patching DNS records (%d -> %d)", this.records.length, records.length); - return this.write(); - } + return this.write(); + } - private async write() { - if (!this.path || !this.writable()) { - return; - } + private async write() { + if (!this.path || !this.writable()) { + return; + } - while (this.writeLock) { - await setTimeout(100); - } + while (this.writeLock) { + await setTimeout(100); + } - this.writeLock = true; - log.debug('config', 'Writing updated DNS configuration to %s', this.path); - const data = JSON.stringify(this.records, null, 4); - await writeFile(this.path, data); - this.writeLock = false; - } + this.writeLock = true; + log.debug("config", "Writing updated DNS configuration to %s", this.path); + const data = JSON.stringify(this.records, null, 4); + await writeFile(this.path, data); + this.writeLock = false; + } } export async function loadHeadscaleDNS(path?: string) { - if (!path) { - return; - } + if (!path) { + return; + } - log.debug('config', 'Loading Headscale DNS configuration file: %s', path); - const { w, r } = await validateConfigPath(path); - if (!r) { - return new HeadscaleDNSConfig('no'); - } + log.debug("config", "Loading Headscale DNS configuration file: %s", path); + const { w, r } = await validateConfigPath(path); + if (!r) { + return new HeadscaleDNSConfig("no"); + } - const records = await loadConfigFile(path); - if (!records) { - return new HeadscaleDNSConfig('no'); - } + const records = await loadConfigFile(path); + if (!records) { + 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) { - try { - await access(path, constants.F_OK | constants.R_OK); - log.info('config', 'Found a valid Headscale DNS file at %s', path); - } catch (error) { - log.error('config', 'Unable to read a Headscale DNS file at %s', path); - log.error('config', '%s', error); - return { w: false, r: false }; - } + try { + await access(path, constants.F_OK | constants.R_OK); + log.info("config", "Found a valid Headscale DNS file at %s", path); + } catch (error) { + log.error("config", "Unable to read a Headscale DNS file at %s", path); + log.error("config", "%s", error); + return { w: false, r: false }; + } - try { - await access(path, constants.F_OK | constants.W_OK); - return { w: true, r: true }; - } catch (error) { - log.warn('config', 'Headscale DNS file at %s is not writable', path); - return { w: false, r: true }; - } + try { + await access(path, constants.F_OK | constants.W_OK); + return { w: true, r: true }; + } catch (error) { + log.warn("config", "Headscale DNS file at %s is not writable", path); + return { w: false, r: true }; + } } async function loadConfigFile(path: string) { - log.debug('config', 'Reading Headscale DNS file at %s', path); - try { - const data = await readFile(path, 'utf8'); - const records = JSON.parse(data) as DNSRecord[]; - return records; - } catch (e) { - log.error('config', 'Error reading Headscale DNS file at %s', path); - log.error('config', '%s', e); - return false; - } + log.debug("config", "Reading Headscale DNS file at %s", path); + try { + const data = await readFile(path, "utf8"); + const records = JSON.parse(data) as DNSRecord[]; + return records; + } catch (e) { + log.error("config", "Error reading Headscale DNS file at %s", path); + log.error("config", "%s", e); + return false; + } } diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index ad8e930..437f83c 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -1,422 +1,373 @@ -import { constants, access, readFile, writeFile } from 'node:fs/promises'; -import { exit } from 'node:process'; -import { setTimeout } from 'node:timers/promises'; -import { type } from 'arktype'; -import { Document, parseDocument } from 'yaml'; -import log from '~/utils/log'; -import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from './config-dns'; -import { headscaleConfig } from './config-schema'; +import { constants, access, readFile, writeFile } from "node:fs/promises"; +import { exit } from "node:process"; +import { setTimeout } from "node:timers/promises"; + +import { type } from "arktype"; +import { Document, parseDocument } from "yaml"; + +import log from "~/utils/log"; + +import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns"; +import { headscaleConfig } from "./config-schema"; interface PatchConfig { - path: string; - value: unknown; + path: string; + value: unknown; } // 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 // patch it and to query it for its mode class HeadscaleConfig { - private config?: typeof headscaleConfig.infer; - private document?: Document; - private access: 'rw' | 'ro' | 'no'; - private path?: string; - private writeLock = false; - private dns?: HeadscaleDNSConfig; + private config?: typeof headscaleConfig.infer; + private document?: Document; + private access: "rw" | "ro" | "no"; + private path?: string; + private writeLock = false; + private dns?: HeadscaleDNSConfig; - constructor( - access: 'rw' | 'ro' | 'no', - dns?: HeadscaleDNSConfig, - config?: typeof headscaleConfig.infer, - document?: Document, - path?: string, - ) { - this.access = access; - this.config = config; - this.document = document; - this.path = path; - this.dns = dns; - } + constructor( + access: "rw" | "ro" | "no", + dns?: HeadscaleDNSConfig, + config?: typeof headscaleConfig.infer, + document?: Document, + path?: string, + ) { + this.access = access; + this.config = config; + this.document = document; + this.path = path; + this.dns = dns; + } - readable() { - return this.access !== 'no'; - } + readable() { + return this.access !== "no"; + } - writable() { - return this.access === 'rw'; - } + writable() { + return this.access === "rw"; + } - get c() { - return this.config; - } + get c() { + return this.config; + } - get d() { - if (this.dns) { - return this.dns.r; - } + get d() { + if (this.dns) { + return this.dns.r; + } - return this.config?.dns.extra_records ?? []; - } + return this.config?.dns.extra_records ?? []; + } - async patch(patches: PatchConfig[]) { - if (!this.path || !this.document || !this.readable() || !this.writable()) { - return; - } + async patch(patches: PatchConfig[]) { + if (!this.path || !this.document || !this.readable() || !this.writable()) { + return; + } - log.debug('config', 'Patching Headscale configuration'); - for (const patch of patches) { - const { path, value } = patch; - log.debug('config', 'Patching %s with %o', path, value); + log.debug("config", "Patching Headscale configuration"); + for (const patch of patches) { + const { path, value } = patch; + log.debug("config", "Patching %s with %o", path, value); - // 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 - // to be split correctly. + // 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 + // to be split correctly. - // 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 - // quote, and then we skip the next character, which should be a dot. - // If it's not a quote, we split it. - const key = []; - let current = ''; - let quote = false; + // 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 + // quote, and then we skip the next character, which should be a dot. + // If it's not a quote, we split it. + const key = []; + let current = ""; + let quote = false; - for (const char of path) { - if (char === '"') { - quote = !quote; - } + for (const char of path) { + if (char === '"') { + quote = !quote; + } - if (char === '.' && !quote) { - key.push(current); - current = ''; - continue; - } + if (char === "." && !quote) { + key.push(current); + current = ""; + continue; + } - current += char; - } + current += char; + } - key.push(current.replaceAll('"', '')); - if (value === null) { - this.document.deleteIn(key); - continue; - } + key.push(current.replaceAll('"', "")); + if (value === null) { + this.document.deleteIn(key); + continue; + } - this.document.setIn(key, value); - } + this.document.setIn(key, value); + } - // Revalidate our configuration and update the config - // object with the new configuration - log.info('config', 'Revalidating Headscale configuration'); - const config = validateConfig(this.document.toJSON()); - if (!config) { - return; - } + // Revalidate our configuration and update the config + // object with the new configuration + log.info("config", "Revalidating Headscale configuration"); + const config = validateConfig(this.document.toJSON()); + if (!config) { + return; + } - log.debug( - 'config', - 'Writing updated Headscale configuration to %s', - this.path, - ); + log.debug("config", "Writing updated Headscale configuration to %s", this.path); - // We need to lock the writeLock so that we don't try to write - // to the file while we're already writing to it - while (this.writeLock) { - await setTimeout(100); - } + // We need to lock the writeLock so that we don't try to write + // to the file while we're already writing to it + while (this.writeLock) { + await setTimeout(100); + } - this.writeLock = true; - await writeFile(this.path, this.document.toString(), 'utf8'); - this.config = config; - this.writeLock = false; - return; - } + this.writeLock = true; + await writeFile(this.path, this.document.toString(), "utf8"); + this.config = config; + this.writeLock = false; + return; + } - /** - * Adds a DNS record to the Headscale configuration. - * Differentiates between the file mode and config mode automatically. - * @param record The DNS record to add. - * @returns True if we need to restart the integration. - */ - async addDNS(record: DNSRecord) { - if (this.dns) { - if (!this.dns.readable() || !this.dns.writable()) { - log.debug('config', 'DNS config is not writable'); - return; - } + /** + * Adds a DNS record to the Headscale configuration. + * Differentiates between the file mode and config mode automatically. + * @param record The DNS record to add. + * @returns True if we need to restart the integration. + */ + async addDNS(record: DNSRecord) { + if (this.dns) { + if (!this.dns.readable() || !this.dns.writable()) { + log.debug("config", "DNS config is not writable"); + return; + } - const records = this.dns.r; - if ( - records.some((i) => i.name === record.name && i.type === record.type) - ) { - log.debug('config', 'DNS record already exists'); - return; - } + const records = this.dns.r; + if (records.some((i) => i.name === record.name && i.type === record.type)) { + 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 - // a separate file (which requires an integration restart) - const existing = this.config?.dns.extra_records ?? []; - if ( - existing.some((i) => i.name === record.name && i.type === record.type) - ) { - log.debug('config', 'DNS record already exists'); - return; - } + // If we get here, we need to add to the main config instead of + // a separate file (which requires an integration restart) + const existing = this.config?.dns.extra_records ?? []; + if (existing.some((i) => i.name === record.name && i.type === record.type)) { + log.debug("config", "DNS record already exists"); + return; + } - await this.patch([ - { - path: 'dns.extra_records', - value: Array.from(new Set([...existing, record])), - }, - ]); + await this.patch([ + { + path: "dns.extra_records", + value: Array.from(new Set([...existing, record])), + }, + ]); - return true; - } + return true; + } - /** - * Removes a DNS record from the Headscale configuration. - * Differentiates between the file mode and config mode automatically. - * @param records The DNS record to remove. - * @returns True if we need to restart the integration. - */ - async removeDNS(record: DNSRecord) { - // 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 - // from both places. + /** + * Removes a DNS record from the Headscale configuration. + * Differentiates between the file mode and config mode automatically. + * @param records The DNS record to remove. + * @returns True if we need to restart the integration. + */ + async removeDNS(record: DNSRecord) { + // 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 + // from both places. - if (this.dns) { - if (!this.dns.readable() || !this.dns.writable()) { - log.debug('config', 'DNS config is not writable'); - return; - } + if (this.dns) { + if (!this.dns.readable() || !this.dns.writable()) { + log.debug("config", "DNS config is not writable"); + return; + } - const records = this.dns.r.filter( - (i) => i.name !== record.name || i.type !== record.type, - ); + const records = this.dns.r.filter((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 - // a separate file (which requires an integration restart) - const existing = this.config?.dns.extra_records ?? []; - const filtered = existing.filter( - (i) => i.name !== record.name || i.type !== record.type, - ); + // If we get here, we need to remove from the main config instead of + // a separate file (which requires an integration restart) + const existing = this.config?.dns.extra_records ?? []; + const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type); - // If the length of the existing records is the same as the filtered - // records, then we don't need to do anything - if (existing.length === filtered.length) { - return; - } + // If the length of the existing records is the same as the filtered + // records, then we don't need to do anything + if (existing.length === filtered.length) { + return; + } - await this.patch([ - { - path: 'dns.extra_records', - value: existing.filter( - (i) => i.name !== record.name || i.type !== record.type, - ), - }, - ]); + await this.patch([ + { + path: "dns.extra_records", + value: existing.filter((i) => i.name !== record.name || i.type !== record.type), + }, + ]); - return true; - } + return true; + } } -export async function loadHeadscaleConfig( - path?: string, - strict = true, - dnsPath?: string, -) { - if (!path) { - log.debug('config', 'No Headscale configuration file was provided'); - return new HeadscaleConfig('no'); - } +export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) { + if (!path) { + log.debug("config", "No Headscale configuration file was provided"); + return new HeadscaleConfig("no"); + } - log.debug('config', 'Loading Headscale configuration file: %s', path); - const { r, w } = await validateConfigPath(path); - if (!r) { - return new HeadscaleConfig('no'); - } + log.debug("config", "Loading Headscale configuration file: %s", path); + const { r, w } = await validateConfigPath(path); + if (!r) { + return new HeadscaleConfig("no"); + } - const document = await loadConfigFile(path); - if (!document) { - return new HeadscaleConfig('no'); - } + const document = await loadConfigFile(path); + if (!document) { + return new HeadscaleConfig("no"); + } - if (!strict) { - return new HeadscaleConfig( - w ? 'rw' : 'ro', - new HeadscaleDNSConfig('no'), - augmentUnstrictConfig(document.toJSON()), - document, - path, - ); - } + if (!strict) { + return new HeadscaleConfig( + w ? "rw" : "ro", + new HeadscaleDNSConfig("no"), + augmentUnstrictConfig(document.toJSON()), + document, + path, + ); + } - const config = validateConfig(document.toJSON()); - if (!config) { - return new HeadscaleConfig('no'); - } + const config = validateConfig(document.toJSON()); + if (!config) { + return new HeadscaleConfig("no"); + } - if (config.dns.extra_records && config.dns.extra_records_path) { - log.warn( - 'config', - 'Both extra_records and extra_records_path are set, Headscale will crash', - ); + if (config.dns.extra_records && config.dns.extra_records_path) { + log.warn("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'); - return new HeadscaleConfig('no'); - } + log.warn("config", "Please remove one of them from the configuration file"); + return new HeadscaleConfig("no"); + } - const dns = await loadHeadscaleDNS(dnsPath); - if (dns && !config.dns.extra_records_path) { - log.error( - 'config', - 'Using separate DNS config file but dns.extra_records_path is not set in Headscale config', - ); - log.error( - 'config', - 'Please set `dns.extra_records_path` in the Headscale config', - ); - log.error( - 'config', - 'Or remove `headscale.dns_records_path` from the Headplane config', - ); + const dns = await loadHeadscaleDNS(dnsPath); + if (dns && !config.dns.extra_records_path) { + log.error( + "config", + "Using separate DNS config file but dns.extra_records_path is not set in Headscale config", + ); + log.error("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) { - try { - await access(path, constants.F_OK | constants.R_OK); - log.info( - 'config', - 'Found a valid Headscale configuration file at %s', - path, - ); - } 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 { + await access(path, constants.F_OK | constants.R_OK); + log.info("config", "Found a valid Headscale configuration file at %s", path); + } 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 { - await access(path, constants.F_OK | constants.W_OK); - return { w: true, r: true }; - } catch (error) { - log.warn( - 'config', - 'Headscale configuration file at %s is not writable', - path, - ); - return { w: false, r: true }; - } + try { + await access(path, constants.F_OK | constants.W_OK); + return { w: true, r: true }; + } catch (error) { + log.warn("config", "Headscale configuration file at %s is not writable", path); + return { w: false, r: true }; + } } async function loadConfigFile(path: string) { - log.debug('config', 'Reading Headscale configuration file at %s', path); - try { - const data = await readFile(path, 'utf8'); - const configYaml = parseDocument(data); - if (configYaml.errors.length > 0) { - log.error( - 'config', - 'Cannot parse Headscale configuration file at %s', - path, - ); - for (const error of configYaml.errors) { - log.error('config', ` - ${error.toString()}`); - } + log.debug("config", "Reading Headscale configuration file at %s", path); + try { + const data = await readFile(path, "utf8"); + const configYaml = parseDocument(data); + if (configYaml.errors.length > 0) { + log.error("config", "Cannot parse Headscale configuration file at %s", path); + for (const error of configYaml.errors) { + log.error("config", ` - ${error.toString()}`); + } - return false; - } + return false; + } - return configYaml; - } catch (e) { - log.error( - 'config', - 'Error reading Headscale configuration file at %s', - path, - ); - log.error('config', '%s', e); - return false; - } + return configYaml; + } catch (e) { + log.error("config", "Error reading Headscale configuration file at %s", path); + log.error("config", "%s", e); + return false; + } } export function validateConfig(config: unknown) { - log.debug('config', 'Validating Headscale configuration'); - const result = headscaleConfig(config); - if (result instanceof type.errors) { - log.error('config', 'Error validating Headscale configuration:'); - for (const [number, error] of result.entries()) { - log.error('config', ` - (${number}): ${error.toString()}`); - } + log.debug("config", "Validating Headscale configuration"); + const result = headscaleConfig(config); + if (result instanceof type.errors) { + log.error("config", "Error validating Headscale configuration:"); + for (const [number, error] of result.entries()) { + log.error("config", ` - (${number}): ${error.toString()}`); + } - return; - } + return; + } - return result; + return result; } // If config_strict is false, we set the defaults and disable // the schema checking for the values that are not present function augmentUnstrictConfig(loaded: Partial) { - log.debug('config', 'Augmenting Headscale configuration in non-strict mode'); - const config = { - ...loaded, - tls_letsencrypt_cache_dir: - loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache', - tls_letsencrypt_challenge_type: - loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01', - grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443', - grpc_allow_insecure: loaded.grpc_allow_insecure ?? false, - randomize_client_port: loaded.randomize_client_port ?? false, - unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock', - unix_socket_permission: loaded.unix_socket_permission ?? '0770', + log.debug("config", "Augmenting Headscale configuration in non-strict mode"); + const config = { + ...loaded, + tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache", + tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01", + grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443", + grpc_allow_insecure: loaded.grpc_allow_insecure ?? false, + randomize_client_port: loaded.randomize_client_port ?? false, + unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock", + unix_socket_permission: loaded.unix_socket_permission ?? "0770", - log: loaded.log ?? { - level: 'info', - format: 'text', - }, + log: loaded.log ?? { + level: "info", + format: "text", + }, - logtail: loaded.logtail ?? { - enabled: false, - }, + logtail: loaded.logtail ?? { + enabled: false, + }, - prefixes: loaded.prefixes ?? { - allocation: 'sequential', - v4: '', - v6: '', - }, + prefixes: loaded.prefixes ?? { + allocation: "sequential", + v4: "", + v6: "", + }, - dns: loaded.dns ?? { - nameservers: { - global: [], - split: {}, - }, - search_domains: [], - extra_records: [], - magic_dns: false, - base_domain: 'headscale.net', - }, - }; + dns: loaded.dns ?? { + nameservers: { + global: [], + split: {}, + }, + search_domains: [], + extra_records: [], + magic_dns: false, + base_domain: "headscale.net", + }, + }; - 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', ' - Headplane could very easily crash'); - log.warn('config', ' - Headplane could break your Headscale installation'); - log.warn( - 'config', - ' - The UI could throw random errors/show incorrect data', - ); + 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", " - Headplane could very easily crash"); + log.warn("config", " - Headplane could break your Headscale installation"); + log.warn("config", " - The UI could throw random errors/show incorrect data"); - return config as typeof headscaleConfig.infer; + return config as typeof headscaleConfig.infer; } diff --git a/app/server/headscale/config-schema.ts b/app/server/headscale/config-schema.ts index 4934db5..2a64be9 100644 --- a/app/server/headscale/config-schema.ts +++ b/app/server/headscale/config-schema.ts @@ -1,150 +1,150 @@ -import { type } from 'arktype'; +import { type } from "arktype"; const goBool = type('boolean | "true" | "false"').pipe((v) => { - if (v === 'true') return true; - if (v === 'false') return false; - return v; + if (v === "true") return true; + if (v === "false") return false; + return v; }); -const goDuration = type('0 | string').pipe((v) => { - return v.toString(); +const goDuration = type("0 | string").pipe((v) => { + return v.toString(); }); const databaseConfig = type({ - type: '"sqlite" | "sqlite3"', - sqlite: { - path: 'string', - write_ahead_log: goBool.default(true), - wal_autocheckpoint: 'number = 1000', - }, + type: '"sqlite" | "sqlite3"', + sqlite: { + path: "string", + write_ahead_log: goBool.default(true), + wal_autocheckpoint: "number = 1000", + }, }) - .or({ - type: '"postgres"', - postgres: { - host: 'string', - port: 'number | ""', - name: 'string', - user: 'string', - pass: 'string', - max_open_conns: 'number = 10', - max_idle_conns: 'number = 10', - conn_max_idle_time_secs: 'number = 3600', - ssl: goBool.default(false), - }, - }) - .merge({ - debug: goBool.default(false), - 'gorm?': { - prepare_stmt: goBool.default(true), - parameterized_queries: goBool.default(true), - skip_err_record_not_found: goBool.default(true), - slow_threshold: 'number = 1000', - }, - }); + .or({ + type: '"postgres"', + postgres: { + host: "string", + port: 'number | ""', + name: "string", + user: "string", + pass: "string", + max_open_conns: "number = 10", + max_idle_conns: "number = 10", + conn_max_idle_time_secs: "number = 3600", + ssl: goBool.default(false), + }, + }) + .merge({ + debug: goBool.default(false), + "gorm?": { + prepare_stmt: goBool.default(true), + parameterized_queries: goBool.default(true), + skip_err_record_not_found: goBool.default(true), + slow_threshold: "number = 1000", + }, + }); // Not as strict parsing because we just need the values // to be slightly truthy enough to safely modify them export type HeadscaleConfig = typeof headscaleConfig.infer; export const headscaleConfig = type({ - server_url: 'string', - listen_addr: 'string', - 'metrics_listen_addr?': 'string', - grpc_listen_addr: 'string = ":50433"', - grpc_allow_insecure: goBool.default(false), - noise: { - private_key_path: 'string', - }, - prefixes: { - v4: 'string?', - v6: 'string?', - allocation: '"sequential" | "random" = "sequential"', - }, - derp: { - server: { - enabled: goBool.default(true), - region_id: 'number?', - region_code: 'string?', - region_name: 'string?', - stun_listen_addr: 'string?', - private_key_path: 'string?', - ipv4: 'string?', - ipv6: 'string?', - automatically_add_embedded_derp_region: goBool.default(true), - }, - urls: 'string[]?', - paths: 'string[]?', - auto_update_enabled: goBool.default(true), - update_frequency: goDuration.default('24h'), - }, + server_url: "string", + listen_addr: "string", + "metrics_listen_addr?": "string", + grpc_listen_addr: 'string = ":50433"', + grpc_allow_insecure: goBool.default(false), + noise: { + private_key_path: "string", + }, + prefixes: { + v4: "string?", + v6: "string?", + allocation: '"sequential" | "random" = "sequential"', + }, + derp: { + server: { + enabled: goBool.default(true), + region_id: "number?", + region_code: "string?", + region_name: "string?", + stun_listen_addr: "string?", + private_key_path: "string?", + ipv4: "string?", + ipv6: "string?", + automatically_add_embedded_derp_region: goBool.default(true), + }, + urls: "string[]?", + paths: "string[]?", + auto_update_enabled: goBool.default(true), + update_frequency: goDuration.default("24h"), + }, - disable_check_updates: goBool.default(false), - ephemeral_node_inactivity_timeout: goDuration.default('30m'), - database: databaseConfig, + disable_check_updates: goBool.default(false), + ephemeral_node_inactivity_timeout: goDuration.default("30m"), + database: databaseConfig, - acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"', - acme_email: 'string = ""', - tls_letsencrypt_hostname: 'string = ""', - tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"', - tls_letsencrypt_challenge_type: 'string = "HTTP-01"', - tls_letsencrypt_listen: 'string = ":http"', - 'tls_cert_path?': 'string', - 'tls_key_path?': 'string', + acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"', + acme_email: 'string = ""', + tls_letsencrypt_hostname: 'string = ""', + tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"', + tls_letsencrypt_challenge_type: 'string = "HTTP-01"', + tls_letsencrypt_listen: 'string = ":http"', + "tls_cert_path?": "string", + "tls_key_path?": "string", - log: type({ - format: 'string = "text"', - level: 'string = "info"', - }).default(() => ({ format: 'text', level: 'info' })), + log: type({ + format: 'string = "text"', + level: 'string = "info"', + }).default(() => ({ format: "text", level: "info" })), - 'policy?': { - mode: '"database" | "file" = "file"', - path: 'string?', - }, + "policy?": { + mode: '"database" | "file" = "file"', + path: "string?", + }, - dns: { - magic_dns: goBool.default(true), - base_domain: 'string = "headscale.net"', - override_local_dns: goBool.default(false), - nameservers: type({ - global: type('string[]').default(() => []), - split: type('Record').default(() => ({})), - }).default(() => ({ global: [], split: {} })), - search_domains: type('string[]').default(() => []), - extra_records: type({ - name: 'string', - value: 'string', - type: 'string | "A"', - }) - .array() - .optional(), - extra_records_path: 'string?', - }, + dns: { + magic_dns: goBool.default(true), + base_domain: 'string = "headscale.net"', + override_local_dns: goBool.default(false), + nameservers: type({ + global: type("string[]").default(() => []), + split: type("Record").default(() => ({})), + }).default(() => ({ global: [], split: {} })), + search_domains: type("string[]").default(() => []), + extra_records: type({ + name: "string", + value: "string", + type: 'string | "A"', + }) + .array() + .optional(), + extra_records_path: "string?", + }, - unix_socket: 'string?', - unix_socket_permission: 'string = "0770"', + unix_socket: "string?", + unix_socket_permission: 'string = "0770"', - 'oidc?': { - only_start_if_oidc_is_available: goBool.default(false), - issuer: 'string', - client_id: 'string', - client_secret: 'string?', - client_secret_path: 'string?', - expiry: goDuration.default('180d'), - use_expiry_from_token: goBool.default(false), - scope: type('string[]').default(() => ['openid', 'email', 'profile']), - extra_params: 'Record?', - allowed_domains: 'string[]?', - allowed_groups: 'string[]?', - allowed_users: 'string[]?', - 'pkce?': { - enabled: goBool.default(false), - method: 'string = "S256"', - }, - map_legacy_users: goBool.default(false), - }, + "oidc?": { + only_start_if_oidc_is_available: goBool.default(false), + issuer: "string", + client_id: "string", + client_secret: "string?", + client_secret_path: "string?", + expiry: goDuration.default("180d"), + use_expiry_from_token: goBool.default(false), + scope: type("string[]").default(() => ["openid", "email", "profile"]), + extra_params: "Record?", + allowed_domains: "string[]?", + allowed_groups: "string[]?", + allowed_users: "string[]?", + "pkce?": { + enabled: goBool.default(false), + method: 'string = "S256"', + }, + map_legacy_users: goBool.default(false), + }, - 'logtail?': { - enabled: goBool.default(false), - }, + "logtail?": { + enabled: goBool.default(false), + }, - randomize_client_port: goBool.default(false), + randomize_client_port: goBool.default(false), }); diff --git a/app/types/Key.ts b/app/types/Key.ts index 0b41a4d..6bc3f2f 100644 --- a/app/types/Key.ts +++ b/app/types/Key.ts @@ -1,7 +1,7 @@ export type Key = { - id: string; - prefix: string; - expiration: string; - createdAt: Date; - lastSeen: Date; + id: string; + prefix: string; + expiration: string; + createdAt: Date; + lastSeen: Date; }; diff --git a/app/types/PreAuthKey.ts b/app/types/PreAuthKey.ts index 8e59a07..252e93d 100644 --- a/app/types/PreAuthKey.ts +++ b/app/types/PreAuthKey.ts @@ -1,13 +1,13 @@ -import type { User } from './User'; +import type { User } from "./User"; export interface PreAuthKey { - id: string; - key: string; - user: User | null; - reusable: boolean; - ephemeral: boolean; - used: boolean; - expiration: string; - createdAt: string; - aclTags: string[]; + id: string; + key: string; + user: User | null; + reusable: boolean; + ephemeral: boolean; + used: boolean; + expiration: string; + createdAt: string; + aclTags: string[]; } diff --git a/app/types/Route.ts b/app/types/Route.ts index 6fdcb55..670a462 100644 --- a/app/types/Route.ts +++ b/app/types/Route.ts @@ -1,13 +1,13 @@ -import type { Machine } from './Machine'; +import type { Machine } from "./Machine"; export interface Route { - id: string; - node: Machine; - prefix: string; - advertised: boolean; - enabled: boolean; - isPrimary: boolean; - createdAt: string; - updatedAt: string; - deletedAt: string; + id: string; + node: Machine; + prefix: string; + advertised: boolean; + enabled: boolean; + isPrimary: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string; } diff --git a/app/types/User.ts b/app/types/User.ts index 06161fb..736af26 100644 --- a/app/types/User.ts +++ b/app/types/User.ts @@ -1,10 +1,10 @@ export interface User { - id: string; - name: string; - createdAt: string; - displayName?: string; - email?: string; - providerId?: string; - provider?: string; - profilePicUrl?: string; + id: string; + name: string; + createdAt: string; + displayName?: string; + email?: string; + providerId?: string; + provider?: string; + profilePicUrl?: string; } diff --git a/app/types/index.ts b/app/types/index.ts index f0f89f7..61728a2 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -1,6 +1,6 @@ -export * from './Key'; -export * from './Machine'; -export * from './Route'; -export * from './User'; -export * from './PreAuthKey'; -export * from './HostInfo'; +export * from "./Key"; +export * from "./Machine"; +export * from "./Route"; +export * from "./User"; +export * from "./PreAuthKey"; +export * from "./HostInfo"; diff --git a/app/utils/cn.ts b/app/utils/cn.ts index de58645..620d1c3 100644 --- a/app/utils/cn.ts +++ b/app/utils/cn.ts @@ -1,5 +1,5 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); export default cn; diff --git a/app/utils/host-info.ts b/app/utils/host-info.ts index bb18a04..46afa21 100644 --- a/app/utils/host-info.ts +++ b/app/utils/host-info.ts @@ -1,36 +1,36 @@ -import type { HostInfo } from '~/types'; +import type { HostInfo } from "~/types"; export function getTSVersion(host: HostInfo) { - const { IPNVersion } = host; - if (!IPNVersion) { - return 'Unknown'; - } + const { IPNVersion } = host; + if (!IPNVersion) { + return "Unknown"; + } - // IPNVersion is -- - return IPNVersion.split('-')[0]; + // IPNVersion is -- + return IPNVersion.split("-")[0]; } export function getOSInfo(host: HostInfo) { - const { OS, OSVersion } = host; - // OS follows runtime.GOOS but uses iOS and macOS instead of darwin - const formattedOS = formatOS(OS); + const { OS, OSVersion } = host; + // OS follows runtime.GOOS but uses iOS and macOS instead of darwin + const formattedOS = formatOS(OS); - // Trim in case OSVersion is empty - return `${formattedOS} ${OSVersion ?? ''}`.trim(); + // Trim in case OSVersion is empty + return `${formattedOS} ${OSVersion ?? ""}`.trim(); } function formatOS(os?: string) { - switch (os) { - case 'macOS': - case 'iOS': - return os; - case 'windows': - return 'Windows'; - case 'linux': - return 'Linux'; - case undefined: - return 'Unknown'; - default: - return os; - } + switch (os) { + case "macOS": + case "iOS": + return os; + case "windows": + return "Windows"; + case "linux": + return "Linux"; + case undefined: + return "Unknown"; + default: + return os; + } } diff --git a/app/utils/res.ts b/app/utils/res.ts index d0e1e7a..1cf84b0 100644 --- a/app/utils/res.ts +++ b/app/utils/res.ts @@ -1,39 +1,39 @@ -import { data } from 'react-router'; +import { data } from "react-router"; export function send(payload: T, init?: number | ResponseInit) { - return data(payload, init); + return data(payload, init); } export function send401(payload: T) { - return data(payload, { status: 401 }); + return data(payload, { status: 401 }); } export function data400(message: string) { - return data( - { - success: false, - message, - }, - { status: 400 }, - ); + return data( + { + success: false, + message, + }, + { status: 400 }, + ); } export function data403(message: string) { - return data( - { - success: false, - message, - }, - { status: 403 }, - ); + return data( + { + success: false, + message, + }, + { status: 403 }, + ); } export function data404(message: string) { - return data( - { - success: false, - message, - }, - { status: 404 }, - ); + return data( + { + success: false, + message, + }, + { status: 404 }, + ); } diff --git a/app/utils/time.ts b/app/utils/time.ts index 90d8363..6a352f7 100644 --- a/app/utils/time.ts +++ b/app/utils/time.ts @@ -6,37 +6,37 @@ * - Over 1 month: "X months, Y days ago" */ export function formatTimeDelta(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); - const minutes = Math.floor(diffMs / (1000 * 60)); - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const months = Math.floor(days / 30); + const minutes = Math.floor(diffMs / (1000 * 60)); + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const months = Math.floor(days / 30); - if (minutes < 60) { - return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - } + if (minutes < 60) { + return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } - if (hours < 24) { - const remainingMinutes = minutes % 60; - if (remainingMinutes === 0) { - return `${hours} hour${hours !== 1 ? 's' : ''} ago`; - } - return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`; - } + if (hours < 24) { + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) { + return `${hours} hour${hours !== 1 ? "s" : ""} ago`; + } + return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`; + } - if (days < 30) { - const remainingHours = hours % 24; - if (remainingHours === 0) { - return `${days} day${days !== 1 ? 's' : ''} ago`; - } - return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`; - } + if (days < 30) { + const remainingHours = hours % 24; + if (remainingHours === 0) { + return `${days} day${days !== 1 ? "s" : ""} ago`; + } + return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`; + } - const remainingDays = days % 30; - if (remainingDays === 0) { - return `${months} month${months !== 1 ? 's' : ''} ago`; - } - return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`; + const remainingDays = days % 30; + if (remainingDays === 0) { + return `${months} month${months !== 1 ? "s" : ""} ago`; + } + return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`; } diff --git a/config.example.yaml b/config.example.yaml index d087bb9..978dada 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -165,91 +165,91 @@ integration: # OIDC Configuration for simpler authentication # (This is optional, but recommended for the best experience) # oidc: -# Set to false to define OIDC config without enabling it. -# Useful for Helm charts or generating docs from config files. -# 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: "" - -# 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. +# # Set to false to define OIDC config without enabling it. +# # Useful for Helm charts or generating docs from config files. +# enabled: true # -# Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered -# in your OIDC client configuration on the IdP. If it isn't, users will land -# on the provider's error page after logout. -# use_end_session: false - -# Optional. Override the auto-discovered end_session_endpoint, or supply one -# if your provider does not advertise it via discovery. -# end_session_endpoint: "" - -# Where the identity provider should redirect after RP-initiated logout. -# Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered -# in the OIDC client configuration. If unset, Headplane defaults to its own -# `/admin/login?s=logout` page. -# post_logout_redirect_uri: "" - -# The authentication method to use when communicating with the token endpoint. -# This is fully optional and Headplane will attempt to auto-detect the best -# method and fall back to `client_secret_basic` if unsure. -# token_endpoint_auth_method: "client_secret_post" - -# The client ID for the OIDC client -# For the best experience please ensure this is *identical* to the client_id -# you are using for Headscale. because -# client_id: "your-client-id" - -# The client secret for the OIDC client -# You may also provide `client_secret_path` instead to read a value from disk. -# See https://headplane.net/configuration/#sensitive-values -# client_secret: "" - -# Whether to use PKCE when authenticating users. This is recommended as it -# adds an extra layer of security to the authentication process. Enabling this -# means your OIDC provider must support PKCE and it must be enabled on the -# client. -# use_pkce: true - -# If you want to disable traditional login via Headscale API keys -# disable_api_key_login: false - -# By default profile pictures are pulled from the OIDC provider when -# we go to fetch the userinfo endpoint. Optionally, this can be set to -# "oidc" or "gravatar" as of 0.6.1. -# profile_picture_source: "gravatar" - -# 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 +# # 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: "" +# +# # 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 +# # in your OIDC client configuration on the IdP. If it isn't, users will land +# # on the provider's error page after logout. +# use_end_session: false +# +# # Optional. Override the auto-discovered end_session_endpoint, or supply one +# # if your provider does not advertise it via discovery. +# end_session_endpoint: "" +# +# # Where the identity provider should redirect after RP-initiated logout. +# # Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered +# # in the OIDC client configuration. If unset, Headplane defaults to its own +# # `/admin/login?s=logout` page. +# post_logout_redirect_uri: "" +# +# # The authentication method to use when communicating with the token endpoint. +# # This is fully optional and Headplane will attempt to auto-detect the best +# # method and fall back to `client_secret_basic` if unsure. +# token_endpoint_auth_method: "client_secret_post" +# +# # The client ID for the OIDC client +# # For the best experience please ensure this is *identical* to the client_id +# # you are using for Headscale. +# client_id: "your-client-id" +# +# # The client secret for the OIDC client +# # You may also provide `client_secret_path` instead to read a value from disk. +# # See https://headplane.net/configuration/#sensitive-values +# client_secret: "" +# +# # Whether to use PKCE when authenticating users. This is recommended as it +# # adds an extra layer of security to the authentication process. Enabling +# # this means your OIDC provider must support PKCE and it must be enabled on +# # the client. +# use_pkce: true +# +# # If you want to disable traditional login via Headscale API keys +# disable_api_key_login: false +# +# # By default profile pictures are pulled from the OIDC provider when +# # we go to fetch the userinfo endpoint. Optionally, this can be set to +# # "oidc" or "gravatar" as of 0.6.1. +# profile_picture_source: "gravatar" +# +# # 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 diff --git a/docs/configuration/common-issues.md b/docs/configuration/common-issues.md index 3b741bb..94eac7a 100644 --- a/docs/configuration/common-issues.md +++ b/docs/configuration/common-issues.md @@ -4,12 +4,15 @@ description: 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. ## Login does not work + ::: tip 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: +
@@ -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, ensure that `server.cookie_secure` is set appropriately based on how you are accessing Headplane: + - Serving over HTTPS: `cookie_secure` should be enabled (`true`). - Serving over HTTP: `cookie_secure` should be disabled (`false`). diff --git a/docs/index.md b/docs/index.md index cf5890d..dd17db8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,4 +32,3 @@ features: details: "Manage settings hidden behind Headscale configuration such as DNS, networking, auth controls, etc." icon: "📝" --- - diff --git a/docs/install/index.md b/docs/install/index.md index 94830fd..86a5a41 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -12,15 +12,16 @@ set up your configuration file and then pick the installation method that best suits your needs. ## Configuration + Headplane requires a configuration file to operate. A [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: -| Field | Description | -|---------------------|--------------------------------------------------------| -| **`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.data_path`** | Just a path to keep in mind, especially if you're using Docker. | +| Field | Description | +| -------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **`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.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](../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. ## Deployment Methods + 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: ### [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. - - Requires Docker and Docker Compose to be installed. + +- Recommended for most users due to its simplicity and ease of use. +- 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 - - Suitable for users who prefer not to use Docker. - - Allows for advanced features like network management and remote web SSH. - - Requires manual setup of dependencies and environment. + +- Suitable for users who prefer not to use Docker. +- 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 - - 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. diff --git a/docs/install/limited-mode.md b/docs/install/limited-mode.md index 317f7b3..d9be808 100644 --- a/docs/install/limited-mode.md +++ b/docs/install/limited-mode.md @@ -12,16 +12,18 @@ deployment. Limited mode lacks advanced features such as network management, 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 advanced features, making it suitable for local testing and development. ## Prerequisites + - Docker (and optionally Docker Compose) - 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 + ::: tip If you want to test Limited Mode without Docker, you can follow the [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: + ```bash docker run -d \ -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. ### Optional: Docker Compose + If you prefer using Docker Compose, here is a minimal example of a `compose.yaml` file that runs Headplane in Limited Mode: @@ -54,10 +58,10 @@ services: container_name: headplane restart: unless-stopped ports: - - '3000:3000' + - "3000:3000" volumes: - - '/path/to/your/config.yaml:/etc/headplane/config.yaml' - - '/path/to/data/storage:/var/lib/headplane' + - "/path/to/your/config.yaml:/etc/headplane/config.yaml" + - "/path/to/data/storage:/var/lib/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 may not work as expected. For a full-featured experience with SSO, please use one of the other installation methods. - diff --git a/drizzle.config.ts b/drizzle.config.ts index 4b0e848..4a70e3d 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'drizzle-kit'; +import { defineConfig } from "drizzle-kit"; export default defineConfig({ - dialect: 'sqlite', - schema: './app/server/db/schema.ts', - dbCredentials: { - url: 'file:test/hp_persist.db', - }, + dialect: "sqlite", + schema: "./app/server/db/schema.ts", + dbCredentials: { + url: "file:test/hp_persist.db", + }, }); diff --git a/nix/docs.js b/nix/docs.js index 426d60a..9008b84 100644 --- a/nix/docs.js +++ b/nix/docs.js @@ -1,34 +1,34 @@ import fs from "node:fs"; function renderOptions(options) { - const blocks = Object.keys(options).map((key) => { - const opt = options[key]; - const name = key.split(".").slice(2).join("."); - const lines = []; - lines.push(`## ${name}`); - lines.push(`*Description:* ${opt.description}\n`); - lines.push(`*Type:* ${opt.type}\n`); - if (opt.default) { - lines.push(`*Default:* \`${opt.default.text}\`\n`); - } - if (opt.example) { - lines.push(`*Example:* \`${opt.example.text}\`\n`); - } - return lines.join("\n"); - }); + const blocks = Object.keys(options).map((key) => { + const opt = options[key]; + const name = key.split(".").slice(2).join("."); + const lines = []; + lines.push(`## ${name}`); + lines.push(`*Description:* ${opt.description}\n`); + lines.push(`*Type:* ${opt.type}\n`); + if (opt.default) { + lines.push(`*Default:* \`${opt.default.text}\`\n`); + } + if (opt.example) { + lines.push(`*Example:* \`${opt.example.text}\`\n`); + } + return lines.join("\n"); + }); - return [ - `# NixOS module options + return [ + `# NixOS module options | |All options must be under \`services.headplane\`. | |For example: \`settings.headscale.config_path\` becomes \`services.headplane.settings.headscale.config_path\`.` - .split("|") - .map((s) => s.replace(/\n\s+/g, "")) - .join("\n"), - ] - .concat(blocks) - .join("\n\n"); + .split("|") + .map((s) => s.replace(/\n\s+/g, "")) + .join("\n"), + ] + .concat(blocks) + .join("\n\n"); } const filename = process.argv[2]; diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts index 9db92a2..1924cfb 100644 --- a/tests/generate-openapi-hashes.ts +++ b/tests/generate-openapi-hashes.ts @@ -1,8 +1,8 @@ -import type { OpenAPIV2 } from "openapi-types"; - import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { cwd } from "node:process"; + +import type { OpenAPIV2 } from "openapi-types"; import { request } from "undici"; import { hashOpenApiDocument } from "~/server/headscale/api/hasher"; diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts index b0df62f..d3d3693 100644 --- a/tests/integration/setup/env.ts +++ b/tests/integration/setup/env.ts @@ -1,73 +1,68 @@ -import hashes from '~/openapi-operation-hashes.json'; -import { - createHeadscaleInterface, - type HeadscaleApiInterface, -} from '~/server/headscale/api'; -import { type HeadscaleEnv, startHeadscale } from './start-headscale'; -import { startTailscaleNode, TailscaleNodeEnv } from './start-tailscale'; +import hashes from "~/openapi-operation-hashes.json"; +import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api"; + +import { type HeadscaleEnv, startHeadscale } from "./start-headscale"; +import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale"; export type Version = keyof typeof hashes; export const HS_VERSIONS = Object.keys(hashes) as Version[]; interface VersionStateEntry { - env: HeadscaleEnv; - tailscaleNode: TailscaleNodeEnv; - bootstrap: HeadscaleApiInterface; + env: HeadscaleEnv; + tailscaleNode: TailscaleNodeEnv; + bootstrap: HeadscaleApiInterface; } const versionState = new Map(); async function ensureVersion(version: Version) { - if (versionState.has(version)) { - return versionState.get(version)!; - } + if (versionState.has(version)) { + return versionState.get(version)!; + } - const env = await startHeadscale(version); - const tailscaleNode = await startTailscaleNode( - version, - env.container.getMappedPort(8080), - ); - const bootstrap = await createHeadscaleInterface(env.apiUrl); + const env = await startHeadscale(version); + const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080)); + const bootstrap = await createHeadscaleInterface(env.apiUrl); - const entry = { env, tailscaleNode, bootstrap }; - versionState.set(version, entry); - return entry; + const entry = { env, tailscaleNode, bootstrap }; + versionState.set(version, entry); + return entry; } export async function getBootstrapClient(version: Version) { - const { bootstrap } = await ensureVersion(version); - return bootstrap; + const { bootstrap } = await ensureVersion(version); + return bootstrap; } export async function getRuntimeClient(version: Version) { - const { env, bootstrap } = await ensureVersion(version); - return bootstrap.getRuntimeClient(env.apiKey); + const { env, bootstrap } = await ensureVersion(version); + return bootstrap.getRuntimeClient(env.apiKey); } export async function getIsAtLeast(version: Version) { - const { env, bootstrap } = await ensureVersion(version); - return bootstrap.clientHelpers.isAtleast; + const { env, bootstrap } = await ensureVersion(version); + return bootstrap.clientHelpers.isAtleast; } export async function getNode(version: Version) { - const { tailscaleNode } = await ensureVersion(version); - return { - authCode: tailscaleNode.authCode, - nodeName: tailscaleNode.nodeName, - }; + const { tailscaleNode } = await ensureVersion(version); + return { + authCode: tailscaleNode.authCode, + nodeName: tailscaleNode.nodeName, + }; } export async function stopAllVersions() { - for (const { env, tailscaleNode } of versionState.values()) { - await env.container.stop({ - remove: true, - removeVolumes: true, - }); + for (const { env, tailscaleNode } of versionState.values()) { + await env.container.stop({ + remove: true, + removeVolumes: true, + }); - await tailscaleNode.container.stop({ - remove: true, - removeVolumes: true, - }); - } + await tailscaleNode.container.stop({ + remove: true, + removeVolumes: true, + }); + } - versionState.clear(); + versionState.clear(); } diff --git a/tests/integration/setup/start-tailscale.ts b/tests/integration/setup/start-tailscale.ts index c592d9b..3370315 100644 --- a/tests/integration/setup/start-tailscale.ts +++ b/tests/integration/setup/start-tailscale.ts @@ -1,85 +1,82 @@ -import { createInterface } from 'node:readline'; -import tc from 'testcontainers'; -import hashes from '~/openapi-operation-hashes.json'; +import { createInterface } from "node:readline"; + +import tc from "testcontainers"; + +import hashes from "~/openapi-operation-hashes.json"; export type Version = keyof typeof hashes; export interface TailscaleNodeEnv { - container: tc.StartedTestContainer; - authCode: string; - nodeName: string; + container: tc.StartedTestContainer; + authCode: string; + nodeName: string; } export async function startTailscaleNode( - version: Version, - headscalePort: number, + version: Version, + headscalePort: number, ): Promise { - let resolveAuthCode!: (code: string) => void; - let rejectAuthCode!: (err: Error) => void; - let authCodeResolved = false; + let resolveAuthCode!: (code: string) => void; + let rejectAuthCode!: (err: Error) => void; + let authCodeResolved = false; - const authCodePromise = new Promise((resolve, reject) => { - resolveAuthCode = resolve; - rejectAuthCode = reject; - }); + const authCodePromise = new Promise((resolve, reject) => { + resolveAuthCode = resolve; + rejectAuthCode = reject; + }); - const nodeName = `test-node-${version.replace(/\./g, '-')}`; - const prefix = `http://localhost:8080/register/`; - const container = await new tc.GenericContainer('tailscale/tailscale:latest') - .withNetworkMode('host') - .withEnvironment({ - TS_STATE_DIR: '/tailscale-state', - TS_EXTRA_ARGS: [ - `--login-server=http://localhost:${headscalePort}`, - `--hostname=${nodeName}`, - '--accept-dns=false', - '--accept-routes=false', - ].join(' '), - }) - .withLogConsumer((stream) => { - const rl = createInterface({ input: stream }); + const nodeName = `test-node-${version.replace(/\./g, "-")}`; + const prefix = `http://localhost:8080/register/`; + const container = await new tc.GenericContainer("tailscale/tailscale:latest") + .withNetworkMode("host") + .withEnvironment({ + TS_STATE_DIR: "/tailscale-state", + TS_EXTRA_ARGS: [ + `--login-server=http://localhost:${headscalePort}`, + `--hostname=${nodeName}`, + "--accept-dns=false", + "--accept-routes=false", + ].join(" "), + }) + .withLogConsumer((stream) => { + const rl = createInterface({ input: stream }); - rl.on('line', (line: string) => { - if (authCodeResolved) return; - const idx = line.indexOf(prefix); - if (idx === -1) return; + rl.on("line", (line: string) => { + if (authCodeResolved) return; + const idx = line.indexOf(prefix); + if (idx === -1) return; - const after = line.slice(idx + prefix.length).trim(); - if (!after) return; + const after = line.slice(idx + prefix.length).trim(); + if (!after) return; - const token = after.split(/\s+/)[0]; - if (!token) return; + const token = after.split(/\s+/)[0]; + if (!token) return; - authCodeResolved = true; - resolveAuthCode(token); - rl.close(); - }); + authCodeResolved = true; + resolveAuthCode(token); + rl.close(); + }); - rl.on('close', () => { - if (!authCodeResolved) { - rejectAuthCode( - new Error( - 'Tailscale container log stream closed before auth code was found', - ), - ); - } - }); - }) - .withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000)) - .start(); + rl.on("close", () => { + if (!authCodeResolved) { + rejectAuthCode( + new Error("Tailscale container log stream closed before auth code was found"), + ); + } + }); + }) + .withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000)) + .start(); - const authCode = await Promise.race([ - authCodePromise, - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`), - ), - 25_000, - ), - ), - ]); + const authCode = await Promise.race([ + authCodePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`)), + 25_000, + ), + ), + ]); - return { container, authCode, nodeName }; + return { container, authCode, nodeName }; } diff --git a/tests/integration/setup/vitest-hook.ts b/tests/integration/setup/vitest-hook.ts index 32fc5e0..792b75f 100644 --- a/tests/integration/setup/vitest-hook.ts +++ b/tests/integration/setup/vitest-hook.ts @@ -1,11 +1,11 @@ -import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from './env'; +import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from "./env"; export async function setup() { - for (const version of HS_VERSIONS) { - await getBootstrapClient(version); - } + for (const version of HS_VERSIONS) { + await getBootstrapClient(version); + } } export async function teardown() { - await stopAllVersions(); + await stopAllVersions(); } diff --git a/tests/unit/config/secret-path.test.ts b/tests/unit/config/secret-path.test.ts index 4c3782f..d3c8fea 100644 --- a/tests/unit/config/secret-path.test.ts +++ b/tests/unit/config/secret-path.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "vitest"; import type { PartialHeadplaneConfigWithPaths } from "~/server/config/config-schema"; - import { ConfigError } from "~/server/config/error"; import { loadConfigKeyPaths } from "~/server/config/load"; diff --git a/tests/unit/setup/overlay-fs.ts b/tests/unit/setup/overlay-fs.ts index b495ee8..3cb51ee 100644 --- a/tests/unit/setup/overlay-fs.ts +++ b/tests/unit/setup/overlay-fs.ts @@ -1,40 +1,40 @@ -import { vi } from 'vitest'; +import { vi } from "vitest"; const fakeFs = new Map(); export function createFakeFile(path: string, content: string) { - fakeFs.set(path, content); + fakeFs.set(path, content); } export function clearFakeFiles() { - fakeFs.clear(); + fakeFs.clear(); } // @ts-expect-error: I have no clue why vitest's types are wrong here -vi.mock(import('node:fs/promises'), async (importOrig) => { - const orig = await importOrig(); - return { - ...orig, - readFile: (path, options) => { - const p = path.toString(); - if (fakeFs.has(p)) { - const content = fakeFs.get(p)!; - if (typeof options === 'string' || options?.encoding) { - return Promise.resolve(content); - } +vi.mock(import("node:fs/promises"), async (importOrig) => { + const orig = await importOrig(); + return { + ...orig, + readFile: (path, options) => { + const p = path.toString(); + if (fakeFs.has(p)) { + const content = fakeFs.get(p)!; + if (typeof options === "string" || options?.encoding) { + 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) => { - const p = path.toString(); - if (fakeFs.has(p)) { - return Promise.resolve(); - } + access: (path, mode) => { + const p = path.toString(); + if (fakeFs.has(p)) { + return Promise.resolve(); + } - return orig.access.call(this, path, mode); - }, - }; + return orig.access.call(this, path, mode); + }, + }; });