From 86184e3420b1388b5baef157ccdcf5887705d258 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Mon, 1 Dec 2025 02:46:22 -0500 Subject: [PATCH] feat: overhaul config loading --- CHANGELOG.md | 5 +- app/routes/auth/oidc-callback.ts | 2 +- app/server/config/config-schema.ts | 177 ++++++++++ app/server/config/env.ts | 76 ----- app/server/config/error.ts | 74 ++++ app/server/config/integration/docker.ts | 35 +- app/server/config/integration/index.ts | 8 +- app/server/config/integration/kubernetes.ts | 25 +- app/server/config/integration/proc.ts | 22 +- app/server/config/load.ts | 267 +++++++++++++++ app/server/config/loader.ts | 303 ----------------- app/server/config/schema.ts | 227 ------------- app/server/config/utils.ts | 9 + app/server/hp-agent.ts | 2 +- app/server/index.ts | 13 +- app/utils/log.ts | 20 +- config.example.yaml | 92 ++--- package.json | 3 +- pnpm-lock.yaml | 15 +- tests/setupOverlayFs.js | 86 ----- tests/unit/config.test.ts | 354 -------------------- tests/unit/config/config-file.test.ts | 81 +++++ tests/unit/config/env-vars.test.ts | 54 +++ tests/unit/config/secret-path.test.ts | 83 +++++ tests/unit/setup/overlay-fs.ts | 2 +- vitest.config.ts | 2 +- 26 files changed, 903 insertions(+), 1134 deletions(-) create mode 100644 app/server/config/config-schema.ts delete mode 100644 app/server/config/env.ts create mode 100644 app/server/config/error.ts create mode 100644 app/server/config/load.ts delete mode 100644 app/server/config/loader.ts delete mode 100644 app/server/config/schema.ts create mode 100644 app/server/config/utils.ts delete mode 100644 tests/setupOverlayFs.js delete mode 100644 tests/unit/config.test.ts create mode 100644 tests/unit/config/config-file.test.ts create mode 100644 tests/unit/config/env-vars.test.ts create mode 100644 tests/unit/config/secret-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3bed7..365af26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ - Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information). - Attempt to warn against misconfigured cookie settings on the login page. - Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)). - +- Re-worked the configuration loading system with several enhancements: + - It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)). + - Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)). + - Added better testing and validation for configuration loading --- # 0.6.1 (October 12, 2025) diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index f36d484..3b1faca 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -3,7 +3,7 @@ import { count, eq } from 'drizzle-orm'; import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import { ulid } from 'ulidx'; import type { LoadContext } from '~/server'; -import { HeadplaneConfig } from '~/server/config/schema'; +import { HeadplaneConfig } from '~/server/config/config-schema'; import { users } from '~/server/db/schema'; import { Roles } from '~/server/web/roles'; import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc'; diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts new file mode 100644 index 0000000..2dc4c27 --- /dev/null +++ b/app/server/config/config-schema.ts @@ -0,0 +1,177 @@ +import { type } from 'arktype'; +import DockerIntegration from './integration/docker'; +import KubernetesIntegration from './integration/kubernetes'; +import ProcIntegration from './integration/proc'; +import { deprecatedField } from './utils'; + +export const pathSupportedKeys = [ + 'server.cookie_secret', + 'oidc.client_secret', + 'oidc.headscale_api_key', + 'integration.agent.pre_authkey', +] as const; + +const serverConfig = type({ + host: 'string.ip = "127.0.0.1"', + port: 'number.integer = 3000', + data_path: 'string.lower = "/var/lib/headplane/"', + + cookie_secret: '(32 <= string <= 32)', + cookie_secure: 'boolean = true', + cookie_domain: 'string.lower?', + cookie_max_age: 'number.integer = 86400', +}); + +const partialServerConfig = type({ + host: 'string.ip?', + port: 'number.integer?', + data_path: 'string.lower?', + + cookie_secret: '(32 <= string <= 32)?', + cookie_secure: 'boolean?', + cookie_domain: 'string.lower?', + cookie_max_age: 'number.integer?', +}); + +const headscaleConfig = type({ + url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), + public_url: type('string.url') + .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) + .optional(), + config_path: 'string.lower?', + config_strict: 'boolean = true', + dns_records_path: 'string.lower?', + tls_cert_path: 'string.lower?', +}); + +const partialHeadscaleConfig = type({ + url: type('string.url') + .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) + .optional(), + public_url: type('string.url') + .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) + .optional(), + config_path: 'string.lower?', + config_strict: 'boolean?', + dns_records_path: 'string.lower?', + tls_cert_path: 'string.lower?', +}); + +const oidcConfig = type({ + issuer: 'string.url', + client_id: 'string', + client_secret: 'string', + headscale_api_key: 'string', + redirect_uri: 'string.url?', + disable_api_key_login: 'boolean = false', + scope: 'string = "openid email profile"', + extra_params: 'Record?', + profile_picture_source: '"oidc" | "gravatar" = "oidc"', + + authorization_endpoint: 'string.url?', + token_endpoint: 'string.url?', + userinfo_endpoint: 'string.url?', + + // Old/deprecated options + user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', + strict_validation: type('unknown').narrow(deprecatedField()).optional(), + token_endpoint_auth_method: type('unknown') + .narrow(deprecatedField()) + .optional(), +}); + +const partialOidcConfig = type({ + issuer: 'string.url?', + client_id: 'string?', + client_secret: 'string?', + headscale_api_key: 'string?', + redirect_uri: 'string.url?', + disable_api_key_login: 'boolean?', + scope: 'string?', + extra_params: 'Record?', + profile_picture_source: '"oidc" | "gravatar"?', + + authorization_endpoint: 'string.url?', + token_endpoint: 'string.url?', + userinfo_endpoint: 'string.url?', + + // Old/deprecated options + user_storage_file: 'string.lower?', + strict_validation: type('unknown').narrow(deprecatedField()).optional(), + token_endpoint_auth_method: type('unknown') + .narrow(deprecatedField()) + .optional(), +}); + +const agentConfig = type({ + enabled: 'boolean', + host_name: 'string = "headplane-agent"', + pre_authkey: 'string', + cache_ttl: 'number.integer = 180000', + cache_path: 'string = "/var/lib/headplane/agent_cache.json"', + executable_path: 'string = "/usr/libexec/headplane/agent"', + work_dir: 'string = "/var/lib/headplane/agent"', +}); + +const partialAgentConfig = type({ + enabled: 'boolean?', + host_name: 'string?', + pre_authkey: 'string?', + cache_ttl: 'number.integer?', + cache_path: 'string?', + executable_path: 'string?', + work_dir: 'string?', +}); + +const integrationConfig = type({ + docker: DockerIntegration.configSchema.full, + kubernetes: KubernetesIntegration.configSchema.full, + proc: ProcIntegration.configSchema.full, + agent: agentConfig.optional(), +}).partial(); + +export const partialIntegrationConfig = type({ + docker: DockerIntegration.configSchema.partial, + kubernetes: KubernetesIntegration.configSchema.partial, + proc: ProcIntegration.configSchema.partial, + agent: partialAgentConfig.optional(), +}).partial(); + +export const headplaneConfig = type({ + debug: 'boolean = false', + server: serverConfig, + headscale: headscaleConfig, + oidc: oidcConfig.optional(), + integration: integrationConfig.optional(), +}).onDeepUndeclaredKey('delete'); + +export const partialHeadplaneConfig = type({ + debug: 'boolean?', + server: partialServerConfig.optional(), + headscale: partialHeadscaleConfig.optional(), + oidc: partialOidcConfig.optional(), + integration: partialIntegrationConfig.optional(), +}); + +export type HeadplaneConfig = typeof headplaneConfig.infer; +export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer; + +type DotNotationToObjects< + T extends string, + V, +> = T extends `${infer K}.${infer Rest}` + ? { [P in K]?: DotNotationToObjects } + : { [P in `${T}_path`]?: V }; + +type ObjectDeepMerge = T extends object + ? { + [K in keyof T]: T[K] extends object ? ObjectDeepMerge : T[K]; + } + : T; + +type ConfigWithPathKeys = ObjectDeepMerge< + DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined> +>; + +export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & + ConfigWithPathKeys; diff --git a/app/server/config/env.ts b/app/server/config/env.ts deleted file mode 100644 index 0dc09a6..0000000 --- a/app/server/config/env.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { exit } from 'node:process'; -import { type } from 'arktype'; -import log from '~/utils/log'; - -// Custom type for boolean environment variables, allowing for values like -// 1, true, yes, and on to count as a truthy value. -const booleanEnv = type('string | undefined').pipe((v) => { - return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? ''); -}); - -export const envVariables = { - debugLog: 'HEADPLANE_DEBUG_LOG', - envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES', - configPath: 'HEADPLANE_CONFIG_PATH', -} as const; - -export function configureLogger(env: string | undefined) { - const result = booleanEnv(env); - if (result instanceof type.errors) { - log.error( - 'config', - 'HEADPLANE_DEBUG_LOG value is invalid: %s', - result.summary, - ); - log.info('config', 'Using a default value: false'); - log.debug = () => {}; // Disable debug logging if the value is invalid - log.debugEnabled = false; - return; - } - - if (result === false) { - log.debug = () => {}; // Disable debug logging if the value is false - log.debugEnabled = false; - return; - } - - log.debug('config', 'Debug logging has been enabled'); - log.debug('config', 'It is recommended this be disabled in production'); -} - -export interface EnvOverrides { - loadEnv: boolean; - path: string; -} - -export function configureConfig(overrides: { - loadEnv: string | undefined; - path: string | undefined; -}): EnvOverrides { - const loadResult = booleanEnv(overrides.loadEnv); - if (loadResult instanceof type.errors) { - log.error( - 'config', - 'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s', - loadResult.summary, - ); - - exit(1); - } - - const pathResult = type('string | undefined')(overrides.path); - if (pathResult instanceof type.errors) { - log.error( - 'config', - 'HEADPLANE_CONFIG_PATH value is invalid: %s', - pathResult.summary, - ); - - exit(1); - } - - return { - loadEnv: loadResult, - path: pathResult ?? '/etc/headplane/config.yaml', - }; -} diff --git a/app/server/config/error.ts b/app/server/config/error.ts new file mode 100644 index 0000000..02f92c8 --- /dev/null +++ b/app/server/config/error.ts @@ -0,0 +1,74 @@ +interface ErrorCodes { + CONFLICTING_SECRET_PATH_FIELD: { + fieldName: string; + }; + + INVALID_REQUIRED_FIELDS: { + messages: string[]; + }; + + MISSING_INTERPOLATION_VARIABLE: { + pathKey: string; + variableName: string; + }; + + MISSING_SECRET_FILE: { + pathKey: string; + filePath: string; + }; +} + +const translationsWithVars: { + [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}".`, + + 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; + + /** + * 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); + } +} diff --git a/app/server/config/integration/docker.ts b/app/server/config/integration/docker.ts index b9e2283..efb32bb 100644 --- a/app/server/config/integration/docker.ts +++ b/app/server/config/integration/docker.ts @@ -1,9 +1,9 @@ import { access, constants } from 'node:fs/promises'; import { setTimeout } from 'node:timers/promises'; +import { type } from 'arktype'; import { Client } from 'undici'; import type { RuntimeApiClient } from '~/server/headscale/api/endpoints'; import log from '~/utils/log'; -import type { HeadplaneConfig } from '../schema'; import { Integration } from './abstract'; interface DockerContainer { @@ -11,8 +11,25 @@ interface DockerContainer { Names: string[]; } -type T = NonNullable['docker']; -export default class DockerIntegration extends Integration { +const configSchema = { + full: type({ + enabled: 'boolean', + container_name: 'string?', + container_label: 'string = "me.tale.headplane.target=headscale"', + socket: 'string = "unix:///var/run/docker.sock"', + }), + + partial: type({ + enabled: 'boolean?', + container_name: 'string?', + container_label: 'string?', + socket: 'string?', + }).partial(), +}; + +export default class DockerIntegration extends Integration< + typeof configSchema.full.infer +> { private maxAttempts = 10; private client: Client | undefined; private containerId: string | undefined; @@ -21,6 +38,10 @@ export default class DockerIntegration extends Integration { return 'Docker'; } + static get configSchema() { + return configSchema; + } + async getContainerName(label: string, value: string): Promise { if (!this.client) { throw new Error('Docker client is not initialized'); @@ -60,7 +81,7 @@ export default class DockerIntegration extends Integration { // Basic configuration check, the name overrides the container_label // selector because of legacy support. const { container_name, container_label } = this.context; - if (container_name.length === 0 && container_label.length === 0) { + if (container_name?.length === 0 && container_label.length === 0) { log.error( 'config', 'Missing a Docker `container_name` or `container_label`', @@ -127,7 +148,7 @@ export default class DockerIntegration extends Integration { const qp = new URLSearchParams({ filters: JSON.stringify( - container_name.length > 0 + container_name != null && container_name.length > 0 ? { name: [container_name] } : { label: [container_label] }, ), @@ -151,7 +172,7 @@ export default class DockerIntegration extends Integration { const data = (await res.body.json()) as DockerContainer[]; if (data.length > 1) { - if (container_name.length > 0) { + if (container_name != null && container_name.length > 0) { log.error( 'config', `Found multiple containers with name ${container_name}`, @@ -167,7 +188,7 @@ export default class DockerIntegration extends Integration { } if (data.length === 0) { - if (container_name.length > 0) { + if (container_name != null && container_name.length > 0) { log.error( 'config', `No container found with the name ${container_name}`, diff --git a/app/server/config/integration/index.ts b/app/server/config/integration/index.ts index 3bef910..cb64535 100644 --- a/app/server/config/integration/index.ts +++ b/app/server/config/integration/index.ts @@ -1,5 +1,5 @@ -import { HeadplaneConfig } from '~/server/config/schema'; import log from '~/utils/log'; +import type { HeadplaneConfig } from '../config-schema'; import dockerIntegration from './docker'; import kubernetesIntegration from './kubernetes'; import procIntegration from './proc'; @@ -47,16 +47,16 @@ function getIntegration(integration: HeadplaneConfig['integration']) { if (docker?.enabled) { log.info('config', 'Using Docker integration'); - return new dockerIntegration(integration?.docker); + return new dockerIntegration(integration!.docker!); } if (k8s?.enabled) { log.info('config', 'Using Kubernetes integration'); - return new kubernetesIntegration(integration?.kubernetes); + return new kubernetesIntegration(integration!.kubernetes!); } if (proc?.enabled) { log.info('config', 'Using Proc integration'); - return new procIntegration(integration?.proc); + return new procIntegration(integration!.proc!); } } diff --git a/app/server/config/integration/kubernetes.ts b/app/server/config/integration/kubernetes.ts index 23801a7..aa4ea42 100644 --- a/app/server/config/integration/kubernetes.ts +++ b/app/server/config/integration/kubernetes.ts @@ -4,9 +4,9 @@ import { join, resolve } from 'node:path'; import { kill } from 'node:process'; import { setTimeout } from 'node:timers/promises'; 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 type { HeadplaneConfig } from '../schema'; import { Integration } from './abstract'; // https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40 @@ -15,8 +15,23 @@ const svcCaPath = `${svcRoot}/ca.crt`; const svcTokenPath = `${svcRoot}/token`; const svcNamespacePath = `${svcRoot}/namespace`; -type T = NonNullable['kubernetes']; -export default class KubernetesIntegration extends Integration { +const configSchema = { + full: type({ + enabled: 'boolean', + pod_name: 'string', + validate_manifest: 'boolean = true', + }), + + partial: type({ + enabled: 'boolean?', + pod_name: 'string?', + validate_manifest: 'boolean?', + }).partial(), +}; + +export default class KubernetesIntegration extends Integration< + typeof configSchema.full.infer +> { private pid: number | undefined; private maxAttempts = 10; @@ -24,6 +39,10 @@ export default class KubernetesIntegration extends Integration { return 'Kubernetes (k8s)'; } + static get configSchema() { + return configSchema; + } + async isAvailable() { if (platform() !== 'linux') { log.error('config', 'Kubernetes is only available on Linux'); diff --git a/app/server/config/integration/proc.ts b/app/server/config/integration/proc.ts index 4252936..1c51603 100644 --- a/app/server/config/integration/proc.ts +++ b/app/server/config/integration/proc.ts @@ -3,13 +3,25 @@ import { platform } from 'node:os'; import { join, resolve } from 'node:path'; import { kill } from 'node:process'; import { setTimeout } from 'node:timers/promises'; +import { type } from 'arktype'; import type { RuntimeApiClient } from '~/server/headscale/api/endpoints'; import log from '~/utils/log'; -import type { HeadplaneConfig } from '../schema'; +import type { HeadplaneConfig } from '../config-schema'; import { Integration } from './abstract'; -type T = NonNullable['proc']; -export default class ProcIntegration extends Integration { +const configSchema = { + full: type({ + enabled: 'boolean', + }), + + partial: type({ + enabled: 'boolean?', + }).partial(), +}; + +export default class ProcIntegration extends Integration< + typeof configSchema.full.infer +> { private pid: number | undefined; private maxAttempts = 10; @@ -17,6 +29,10 @@ export default class ProcIntegration extends Integration { return 'Native Linux (/proc)'; } + static get configSchema() { + return configSchema; + } + async isAvailable() { if (platform() !== 'linux') { log.error('config', '/proc is only available on Linux'); diff --git a/app/server/config/load.ts b/app/server/config/load.ts new file mode 100644 index 0000000..41d1cc4 --- /dev/null +++ b/app/server/config/load.ts @@ -0,0 +1,267 @@ +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'; + +/** + * Main entrypoint that attempts to load and merge configuration from both + * a YAML config file (if available) and environment variables. Importantly, + * the environment variables will override any values set in the config file. + * + * The function also supports loading secret values from file paths for + * specific configuration keys (e.g., certificates, private keys) by checking + * for corresponding `_path` suffixed environment variables or config file + * entries. + * + * @param configPathOverride Used for testing to override the config file path + * @returns @ref{HeadplaneConfig} The fully validated configuration + * @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 fileConfig = await loadConfigFile(configPath); + const envConfig = await loadConfigEnv(); + + 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()), + }); + } + + return finalConfig; +} + +/** + * Attempts to load configuration from a YAML file at the specified path. + * If the file is not accessible, it returns undefined. + * + * @param path The file path to load the configuration from + * @returns A partial configuration object or undefined + * @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; + } + + 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; +} + +/** + * Loads configuration overrides from environment variables prefixed with + * `HEADPLANE_`. Nested configuration keys can be represented using double + * underscores (`__`). For example, `HEADPLANE_SERVER__PORT=8080` would set + * the `server.port` configuration key to `8080`. + * + * @returns A partial configuration object or undefined + * @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', + ); + } + + 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 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; +} + +/** + * Deeply merges multiple objects together. Later objects in the arguments + * list will override properties of earlier objects. + * + * @param objects The objects to merge + * @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; + } + } + } + + return result as T; +} + +/** + * Sets a value deeply within an object based on the provided path. + * + * @param obj The object to set the value in + * @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] = {}; + } + + current = current[key] as { [key: string]: unknown }; + } + + current[path[path.length - 1]] = value; +} + +/** + * Parses an environment variable string value into an appropriate type. + * Supports booleans, null, undefined, and numbers. Falls back to string. + * + * @param value The environment variable string value + * @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; + + if (/^-?\d+(\.\d+)?$/.test(v)) { + const num = Number(v); + if (!Number.isNaN(num)) return num; + } + + return value; +} + +/** + * For configuration keys that support loading from file paths (e.g., + * certificates, private keys), this function checks for corresponding + * `_path` suffixed keys and loads the file content if the main key is + * not already set. + * + * @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('.')); + + if (pathValue == null || typeof pathValue !== 'string') { + continue; + } + + 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, + }); + } + + 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, + }); + } + } +} + +/** + * Deeply retrieves a value from an object based on the provided path. + * + * @param obj The object to retrieve the value from + * @param path An array of keys representing the path to retrieve + * @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; + } + + current = current[segment] as { [key: string]: unknown }; + } + + return current; +} diff --git a/app/server/config/loader.ts b/app/server/config/loader.ts deleted file mode 100644 index f3deea8..0000000 --- a/app/server/config/loader.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { access, constants, readFile } from 'node:fs/promises'; -import { env } from 'node:process'; -import { type } from 'arktype'; -import { configDotenv } from 'dotenv'; -import { parseDocument } from 'yaml'; -import log from '~/utils/log'; -import { EnvOverrides, envVariables } from './env'; -import { - HeadplaneConfig, - headplaneConfig, - partialHeadplaneConfig, -} from './schema'; - -// Custom error for config issues -export class ConfigError extends Error { - constructor(message: string) { - super(message); - this.name = 'ConfigError'; - } -} - -/** - * Interpolate environment variables in a string - * Replaces ${VAR_NAME} patterns with the actual environment variable values - */ -export function interpolateEnvVars(str: string): string { - return str.replace(/\$\{([^}]+)\}/g, (_, varName) => { - const value = env[varName]; - if (value === undefined) { - throw new ConfigError(`Environment variable "${varName}" not found`); - } - return value; - }); -} - -// loadConfig is a has a lifetime of the entire application and is -// used to load the configuration for Headplane. It is called once. -// -// TODO: Potential for file watching on the configuration -// But this may not be necessary as a use-case anyways -export async function loadConfig({ loadEnv, path }: EnvOverrides) { - log.debug('config', 'Loading configuration file: %s', path); - await validateConfigPath(path); - - const data = await loadConfigFile(path); - if (!data) { - throw new ConfigError('Failed to load configuration file'); - } - - let config = validateConfig({ ...data, debug: log.debugEnabled }); - - if (!loadEnv) { - log.debug('config', 'Environment variable overrides are disabled'); - log.debug('config', 'This also disables the loading of a .env file'); - const moddedConfig = await loadSecretsFromFiles(config); - log.debug('config', 'Loaded file-based secrets'); - return moddedConfig; - } - - log.info('config', 'Loading a .env file (if available)'); - configDotenv({ override: true, quiet: true }); - const merged = coalesceEnv(config); - if (merged) config = merged; - if (config.headscale && typeof config.headscale.config_path === 'string') { - config.headscale.config_path = interpolateEnvVars( - config.headscale.config_path, - ); - } - - const moddedConfig = await loadSecretsFromFiles(config); - log.debug('config', 'Loaded file-based secrets'); - - return moddedConfig; -} - -/** - * Recursively walks the config object; for any key in the whitelist of secret path keys, - * reads that file and assigns its contents to the corresponding key - * without the suffix, then removes the "_path" property. - */ -const SECRET_PATH_KEYS = [ - 'pre_authkey_path', - 'client_secret_path', - 'headscale_api_key_path', - 'cookie_secret_path', -] as const; - -// For fast set hashing lookups, but we still need the array for typings -const SECRET_PATH_KEY_SET = new Set(SECRET_PATH_KEYS); - -type SecretPathKey = (typeof SECRET_PATH_KEYS)[number]; -type StripPath = S extends `${infer T}_path` ? T : never; -type KeysToPromote = Extract; -type MappedKeys = StripPath>; - -type NonNullablized = Omit | MappedKeys> & { - [K in MappedKeys]-?: string; -}; - -type NestedNonNullablized = T extends readonly (infer U)[] - ? readonly NestedNonNullablized[] - : T extends (infer U)[] - ? NestedNonNullablized[] - : T extends object - ? { - [K in keyof NonNullablized]: NestedNonNullablized< - NonNullablized[K] - >; - } - : T; - -async function loadSecretsFromFiles( - obj: T, -): Promise> { - // Work with a Record so we can mutate/delete properties - const record = obj as Record; - - for (const key of Object.keys(record)) { - const val = record[key]; - - if (val && typeof val === 'object') { - // recurse into nested objects - record[key] = await loadSecretsFromFiles(val); - continue; - } - - if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') { - try { - const path = interpolateEnvVars(val); - const content = await readFile(path, 'utf8'); - const secretKey = key.slice(0, -5); // drop '_path' - record[secretKey] = content.trim(); - delete record[key]; - log.debug('config', 'Loaded secret from %s → %s', val, secretKey); - } catch (err) { - if (err instanceof ConfigError) throw err; - log.error('config', 'Failed to read secret file %s: %s', val, err); - throw new ConfigError(`Failed to read secret file ${val}: ${err}`); - } - } - } - - // Cast back to the original T so callers keep their precise type - return record as NestedNonNullablized; -} - -async function validateConfigPath(path: string) { - try { - await access(path, constants.F_OK | constants.R_OK); - log.info('config', 'Found a valid configuration file at %s', path); - return true; - } catch (error) { - log.error('config', 'Unable to read a configuration file at %s', path); - log.error('config', '%s', error); - throw new ConfigError( - `Unable to read configuration file at ${path}: ${error}`, - ); - } -} - -async function loadConfigFile(path: string): Promise { - log.debug('config', 'Reading 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 configuration file at %s', path); - for (const error of configYaml.errors) { - log.error('config', ` - ${error.toString()}`); - } - - throw new ConfigError(`Cannot parse configuration file at ${path}`); - } - - if (configYaml.warnings.length > 0) { - log.warn( - 'config', - 'Warnings while parsing configuration file at %s', - path, - ); - for (const warning of configYaml.warnings) { - log.warn('config', ` - ${warning.toString()}`); - } - } - - return configYaml.toJSON() as unknown; - } catch (e) { - log.error('config', 'Error reading configuration file at %s', path); - log.error('config', '%s', e); - throw new ConfigError(`Error reading configuration file at ${path}: ${e}`); - } -} - -export function validateConfig(config: unknown) { - log.debug('config', 'Validating Headplane configuration'); - const result = headplaneConfig(config); - if (result instanceof type.errors) { - const errorMessages = []; - for (const [number, error] of result.entries()) { - const errorMsg = error.toString(); - log.error('config', ` - (${number}): ${errorMsg}`); - errorMessages.push(errorMsg); - } - throw new ConfigError(errorMessages.join('\n')); - } - return result; -} - -function coalesceEnv(config: HeadplaneConfig) { - const envConfig: Record = {}; - const rootKeys: string[] = Object.values(envVariables); - - // Typescript is still insanely stupid at nullish filtering - const vars = Object.entries(env).filter(([key, value]) => { - if (!value) { - return false; - } - - if (!key.startsWith('HEADPLANE_')) { - return false; - } - - // Filter out the rootEnv configurations - if (rootKeys.includes(key)) { - return false; - } - - return true; - }) as [string, string][]; - - log.debug('config', 'Coalescing %s environment variables', vars.length); - for (const [key, value] of vars) { - const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__'); - log.debug( - 'config', - ` - ${key}=${new Array(value.length).fill('*').join('')}`, - ); - - let current = envConfig; - while (configPath.length > 1) { - const path = configPath.shift() as string; - if (!(path in current)) { - current[path] = {}; - } - - current = current[path] as Record; - } - - current[configPath[0]] = value; - } - - const toMerge = coalesceConfig(envConfig); - if (!toMerge) { - return; - } - - // Deep merge the environment variables into the configuration - // This will overwrite any existing values in the configuration - return deepMerge(config, toMerge); -} - -export function coalesceConfig(config: unknown) { - log.debug('config', 'Revalidating config after coalescing variables'); - const out = partialHeadplaneConfig(config); - if (out instanceof type.errors) { - log.error('config', 'Error parsing variables:'); - for (const [number, error] of out.entries()) { - log.error('config', ` - (${number}): ${error.toString()}`); - } - - return; - } - - return out; -} - -type DeepPartial = - | { - [P in keyof T]?: DeepPartial; - } - | undefined; - -function deepMerge(target: T, source: DeepPartial): T { - if (typeof target !== 'object' || typeof source !== 'object') - return source as T; - const result = { ...target } as T; - - for (const key in source) { - const val = source[key]; - if (val === undefined || val === null) { - continue; - } - - if (typeof val === 'object') { - result[key] = deepMerge(result[key], val); - continue; - } - - result[key] = val; - } - - return result; -} diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts deleted file mode 100644 index a3648c5..0000000 --- a/app/server/config/schema.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { type } from 'arktype'; - -const stringToBool = type('string | boolean').pipe((v) => { - if (typeof v === 'string') { - if (v === '1' || v === 'true' || v === 'yes') { - return true; - } - - if (v === '0' || v === 'false' || v === 'no') { - return false; - } - - throw new Error(`Invalid string value for boolean: ${v}`); - } - - return Boolean(v); -}); - -const serverConfig = type({ - host: 'string.ip', - port: type('string | number.integer').pipe((v) => Number(v)), - data_path: 'string = "/var/lib/headplane/"', - cookie_secret: '(32 <= string <= 32)?', - cookie_secret_path: 'string?', - cookie_secure: stringToBool, - cookie_domain: 'string?', - cookie_max_age: 'number.integer = 86400', -}) - .narrow((obj: Record, ctx: any) => { - const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== ''; - const hasPath = - obj.cookie_secret_path != null && obj.cookie_secret_path !== ''; - if (hasVal && hasPath) - return ctx.reject( - `Only one of "cookie_secret" or "cookie_secret_path" may be set.`, - ); - if (!hasVal && !hasPath) - return ctx.reject( - `Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`, - ); - return true; - }) - .onDeepUndeclaredKey('reject'); - -const partialServerConfig = type({ - host: 'string.ip?', - port: type('string | number.integer') - .pipe((v) => Number(v)) - .optional(), - data_path: 'string = "/var/lib/headplane/"', - cookie_secret: '32 <= string <= 32?', - cookie_secret_path: 'string?', - cookie_secure: stringToBool.optional(), - cookie_domain: 'string?', - cookie_max_age: 'number.integer?', -}); - -const oidcConfig = type({ - issuer: 'string.url', - client_id: 'string', - client_secret: 'string?', - client_secret_path: 'string?', - token_endpoint_auth_method: - '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', - redirect_uri: 'string.url?', - user_storage_file: 'string = "/var/lib/headplane/users.json"', - disable_api_key_login: stringToBool, - headscale_api_key: 'string?', - headscale_api_key_path: 'string?', - profile_picture_source: '"oidc" | "gravatar" = "oidc"', - strict_validation: stringToBool.default(true), - scope: 'string = "openid email profile"', - extra_params: 'Record?', - authorization_endpoint: 'string.url?', - token_endpoint: 'string.url?', - userinfo_endpoint: 'string.url?', -}) - .narrow((obj: Record, ctx: any) => { - const hasVal = - obj.headscale_api_key != null && `${obj.headscale_api_key}` !== ''; - const hasPath = - obj.headscale_api_key_path != null && obj.headscale_api_key_path !== ''; - if (hasVal && hasPath) - return ctx.reject( - `Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`, - ); - if (!hasVal && !hasPath) - return ctx.reject( - `Either "headscale_api_key" or "headscale_api_key_path" must be provided.`, - ); - return true; - }) - .onDeepUndeclaredKey('reject'); - -const partialOidcConfig = type({ - issuer: 'string.url?', - client_id: 'string?', - client_secret: 'string?', - client_secret_path: 'string?', - token_endpoint_auth_method: - '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', - redirect_uri: 'string.url?', - user_storage_file: 'string?', - disable_api_key_login: stringToBool.optional(), - headscale_api_key: 'string?', - headscale_api_key_path: 'string?', - profile_picture_source: '("oidc" | "gravatar")?', - strict_validation: stringToBool.default(true), - scope: 'string?', - extra_params: 'Record?', - authorization_endpoint: 'string.url?', - token_endpoint: 'string.url?', - userinfo_endpoint: 'string.url?', -}); - -const headscaleConfig = type({ - url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), - tls_cert_path: 'string?', - public_url: 'string.url?', - config_path: 'string?', - config_strict: stringToBool, - dns_records_path: 'string?', -}).onDeepUndeclaredKey('reject'); - -const partialHeadscaleConfig = type({ - url: type('string.url') - .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) - .optional(), - tls_cert_path: 'string?', - public_url: 'string.url?', - config_path: 'string?', - config_strict: stringToBool.optional(), - dns_records_path: 'string?', -}); - -const agentConfig = type({ - enabled: stringToBool.default(false), - host_name: 'string = "headplane-agent"', - pre_authkey: 'string?', - pre_authkey_path: 'string?', - cache_ttl: 'number.integer = 180000', - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', - executable_path: 'string = "/usr/libexec/headplane/agent"', - work_dir: 'string = "/var/lib/headplane/agent"', -}) - .narrow((obj: Record, ctx: any) => { - const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== ''; - const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== ''; - if (hasVal && hasPath) - return ctx.reject( - `Only one of "pre_authkey" or "pre_authkey_path" may be set.`, - ); - if (!hasVal && !hasPath) - return ctx.reject( - `Either "pre_authkey" or "pre_authkey_path" must be provided.`, - ); - return true; - }) - .onDeepUndeclaredKey('reject'); - -const partialAgentConfig = type({ - enabled: stringToBool.default(false), - host_name: 'string = "headplane-agent"', - pre_authkey: 'string?', - pre_authkey_path: 'string?', - cache_ttl: 'number.integer = 180000', - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', - executable_path: 'string = "/usr/libexec/headplane/agent"', - work_dir: 'string = "/var/lib/headplane/agent"', -}); - -const dockerConfig = type({ - enabled: stringToBool, - container_name: 'string = ""', - container_label: 'string = "me.tale.headplane.target=headscale"', - socket: 'string = "unix:///var/run/docker.sock"', -}); - -const partialDockerConfig = type({ - enabled: stringToBool, - container_name: 'string | undefined', - container_label: 'string | undefined', - socket: 'string | undefined', -}).partial(); - -const kubernetesConfig = type({ - enabled: stringToBool, - pod_name: 'string', - validate_manifest: stringToBool, -}); - -const procConfig = type({ - enabled: stringToBool, -}); - -const integrationConfig = type({ - 'docker?': dockerConfig, - 'kubernetes?': kubernetesConfig, - 'proc?': procConfig, - 'agent?': agentConfig, -}); - -const partialIntegrationConfig = type({ - 'docker?': partialDockerConfig, - 'kubernetes?': kubernetesConfig.partial(), - 'proc?': procConfig.partial(), - 'agent?': partialAgentConfig, -}).partial(); - -export const headplaneConfig = type({ - debug: stringToBool, - server: serverConfig, - 'oidc?': oidcConfig, - 'integration?': integrationConfig, - headscale: headscaleConfig, -}).onDeepUndeclaredKey('delete'); - -export const partialHeadplaneConfig = type({ - debug: stringToBool, - server: partialServerConfig, - 'oidc?': partialOidcConfig, - 'integration?': partialIntegrationConfig, - headscale: partialHeadscaleConfig, -}).partial(); - -export type HeadplaneConfig = typeof headplaneConfig.infer; -export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer; diff --git a/app/server/config/utils.ts b/app/server/config/utils.ts new file mode 100644 index 0000000..937a8d8 --- /dev/null +++ b/app/server/config/utils.ts @@ -0,0 +1,9 @@ +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; + }; +} diff --git a/app/server/hp-agent.ts b/app/server/hp-agent.ts index fe9b059..b7fa71f 100644 --- a/app/server/hp-agent.ts +++ b/app/server/hp-agent.ts @@ -7,7 +7,7 @@ import { inArray } from 'drizzle-orm'; import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core'; import { HostInfo } from '~/types'; import log from '~/utils/log'; -import { HeadplaneConfig } from './config/schema'; +import { HeadplaneConfig } from './config/config-schema'; import { hostInfo } from './db/schema'; export async function createHeadplaneAgent( diff --git a/app/server/index.ts b/app/server/index.ts index 268ea9f..3a8fd79 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,10 +1,9 @@ import { join } from 'node:path'; -import { env, versions } from 'node:process'; +import { versions } from 'node:process'; import { createHonoServer } from 'react-router-hono-server/node'; import log from '~/utils/log'; -import { configureConfig, configureLogger, envVariables } from './config/env'; import { loadIntegration } from './config/integration'; -import { loadConfig } from './config/loader'; +import { loadConfig } from './config/load'; import { createDbClient } from './db/client.server'; import { createHeadscaleInterface } from './headscale/api'; import { loadHeadscaleConfig } from './headscale/config-loader'; @@ -21,13 +20,7 @@ declare global { // This module contains a side-effect because everything running here // exists for the lifetime of the process, making it appropriate. log.info('server', 'Running Node.js %s', versions.node); -configureLogger(env[envVariables.debugLog]); -const config = await loadConfig( - configureConfig({ - loadEnv: env[envVariables.envOverrides], - path: env[envVariables.configPath], - }), -); +const config = await loadConfig(); const db = await createDbClient(join(config.server.data_path, 'hp_persist.db')); const agents = await createHeadplaneAgent( diff --git a/app/utils/log.ts b/app/utils/log.ts index 8e99c83..0e58aa7 100644 --- a/app/utils/log.ts +++ b/app/utils/log.ts @@ -14,10 +14,11 @@ export interface Logger debugEnabled: boolean; } +const logLevels = getLogLevels(); export default { - debugEnabled: true, + debugEnabled: logLevels.includes('debug'), ...Object.fromEntries( - levels.map((level) => [ + logLevels.map((level) => [ level, (category: Category, message: string, ...args: unknown[]) => { const date = new Date().toISOString(); @@ -29,3 +30,18 @@ export default { ]), ), } as Logger; + +function getLogLevels() { + const debugLog = process.env.HEADPLANE_DEBUG_LOG; + if (debugLog == null) { + return ['info', 'warn', 'error']; + } + + const normalized = debugLog.trim().toLowerCase(); + const truthyValues = ['1', 'true', 'yes', 'on']; + if (!truthyValues.includes(normalized)) { + return ['info', 'warn', 'error']; + } + + return ['info', 'warn', 'error', 'debug']; +} diff --git a/config.example.yaml b/config.example.yaml index a58ccaf..60b140d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,15 +1,17 @@ # Configuration for the Headplane server and web application server: + # These are the default values, change them as needed host: "0.0.0.0" port: 3000 - # The secret used to encode and decode web sessions - # Ensure that this is exactly 32 characters long + # The secret used to encode and decode web sessions (must be 32 characters) + # You may also provide `cookie_secret_path` instead to read a value from disk. + # See https://headplane.net/configuration/#sensitive-values cookie_secret: "" - # Should the cookies only work over HTTPS? - # Set to false if running via HTTP without a proxy - # (I recommend this is true in production) + # Whether cookies should be marked as Secure + # * Should be false if running without HTTPs + # * Should be true if running behind a reverse proxy with HTTPs cookie_secure: true # The maximum age of the session cookie in seconds @@ -40,11 +42,11 @@ headscale: # If you use the TLS configuration in Headscale, and you are not using # Let's Encrypt for your certificate, pass in the path to the certificate. - # (This has no effect `url` does not start with `https://`) + # (This has no effect if `url` does not start with `https://`) # tls_cert_path: "/var/lib/headplane/tls.crt" - # Optional, public URL if they differ - # This affects certain parts of the web UI + # Optional, public URL if its different from the `headscale.url` + # This affects certain parts of the web UI which shows Headscale's URL # public_url: "https://headscale.example.com" # Path to the Headscale configuration file @@ -53,9 +55,13 @@ headscale: # in the Web UI, but they cannot be changed. config_path: "/etc/headscale/config.yaml" - # Headplane internally validates the Headscale configuration - # to ensure that it changes the configuration in a safe way. - # If you want to disable this validation, set this to false. + # Whether the Headscale configuration should be strictly validated + # when reading from `config_path`. If true, Headplane will not interact + # with Headscale if there are any issues with the configuration file. + # + # This is recommended to be true for production deployments to, however it + # may not work if you are using a version of Headscale that has configuration + # options unknown to Headplane. config_strict: true # If you are using `dns.extra_records_path` in your Headscale @@ -70,15 +76,17 @@ headscale: # Integration configurations for Headplane to interact with Headscale integration: + # The Headplane agent allows retrieving information about nodes + # This allows the UI to display version, OS, and connectivity data + # You will see the Headplane agent in your Tailnet as a node when + # it connects. agent: - # The Headplane agent allows retrieving information about nodes - # This allows the UI to display version, OS, and connectivity data - # You will see the Headplane agent in your Tailnet as a node when - # it connects. enabled: false + # To connect to your Tailnet, you need to generate a pre-auth key # This can be done via the web UI or through the `headscale` CLI. pre_authkey: "" + # Optionally change the name of the agent in the Tailnet. # host_name: "headplane-agent" @@ -89,10 +97,11 @@ integration: # cache_ttl: 60 # cache_path: /var/lib/headplane/agent_cache.json - # Do not change this unless you are running a custom deployment. # The work_dir represents where the agent will store its data to be able # to automatically reauthenticate with your Tailnet. It needs to be # writable by the user running the Headplane process. + # + # If using Docker, it is best to leave this as the default. # work_dir: "/var/lib/headplane/agent" # Only one of these should be enabled at a time or you will get errors @@ -140,60 +149,55 @@ integration: proc: enabled: false -# OIDC Configuration for simpler authentication -# (This is optional, but recommended for the best experience) -oidc: - issuer: "https://accounts.google.com" + # OIDC Configuration for simpler authentication + # (This is optional, but recommended for the best experience) + # oidc: + # The OIDC issuer URL + # issuer: "https://accounts.google.com" + + # If you are using OIDC, you need to generate an API key + # that can be used to authenticate other sessions when signing in. + # + # This can be done with `headscale apikeys create --expiration 999d` + # 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 the documentation. - + # For some typical providers, see https://headplane.net/features/sso. # authorization_endpoint: "" # token_endpoint: "" # userinfo_endpoint: "" # 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. A lot of OIDC providers will use different - # subjects per user based on the client_id and for this to work correctly - # they need to be identical between Headscale and Headplane. - client_id: "your-client-id" + # you are using for Headscale. because + # client_id: "your-client-id" # The client secret for the OIDC client - # Either this or `client_secret_path` must be set for OIDC to work - client_secret: "" - # You can alternatively set `client_secret_path` to read the secret from disk. - # The path specified can resolve environment variables, making integration - # with systemd's `LoadCredential` straightforward: - # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" + # You may also provide `client_secret_path` instead to read a value from disk. + # See https://headplane.net/configuration/#sensitive-values + # client_secret: "" - # Defaults to 'openid email profile' - # scope: "openid email profile" - - disable_api_key_login: false - token_endpoint_auth_method: "client_secret_post" - - # If you are using OIDC, you need to generate an API key - # that can be used to authenticate other sessions when signing in. - # - # This can be done with `headscale apikeys create --expiration 999d` - headscale_api_key: "" + # If you want to disable traditional login via Headscale API keys + # disable_api_key_login: false # Optional, but highly recommended otherwise Headplane # will attempt to automatically guess this from the issuer # # This should point to your publicly accessibly URL # for your Headplane instance with /admin/oidc/callback - redirect_uri: "http://localhost:3000/admin/oidc/callback" + # redirect_uri: "http://localhost:3000/admin/oidc/callback" # 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" + # 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. diff --git a/package.json b/package.json index bdc70ce..f50c990 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@readme/openapi-parser": "^5.2.1", "@shopify/lang-jsonc": "^1.0.1", "@tailwindcss/vite": "^4.1.17", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -51,11 +52,11 @@ "@xterm/xterm": "^5.5.0", "arktype": "^2.1.26", "clsx": "^2.1.1", - "dotenv": "17.2.3", "drizzle-kit": "^0.31.7", "drizzle-orm": "0.44.7", "isbot": "5.1.32", "jose": "6.1.2", + "js-yaml": "^4.1.1", "lefthook": "^2.0.4", "lucide-react": "^0.540.0", "mime": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c2c3b..4c45158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.17 version: 4.1.17(rolldown-vite@7.1.4(@types/node@24.10.1)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -107,9 +110,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - dotenv: - specifier: 17.2.3 - version: 17.2.3 drizzle-kit: specifier: ^0.31.7 version: 0.31.7 @@ -122,6 +122,9 @@ importers: jose: specifier: 6.1.2 version: 6.1.2 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 lefthook: specifier: ^2.0.4 version: 2.0.4 @@ -2950,10 +2953,6 @@ packages: resolution: {integrity: sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==} engines: {node: '>= 8.0'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} - engines: {node: '>=12'} - drizzle-kit@0.31.7: resolution: {integrity: sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A==} hasBin: true @@ -7661,8 +7660,6 @@ snapshots: transitivePeerDependencies: - supports-color - dotenv@17.2.3: {} - drizzle-kit@0.31.7: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/tests/setupOverlayFs.js b/tests/setupOverlayFs.js deleted file mode 100644 index 439f02f..0000000 --- a/tests/setupOverlayFs.js +++ /dev/null @@ -1,86 +0,0 @@ -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; - -// Simple overlayfs implementation for tests -class OverlayFS { - constructor() { - this.overlays = new Map(); - } - - // Create a virtual file at the given path - createFile(filePath, content) { - this.overlays.set(filePath, content); - } - - // Check if a file exists in our overlay - exists(filePath) { - return this.overlays.has(filePath); - } - - // Read a file from our overlay - readFile(filePath) { - if (this.overlays.has(filePath)) { - return this.overlays.get(filePath); - } - throw new Error(`File not found: ${filePath}`); - } - - // Clean up all overlays - clear() { - this.overlays.clear(); - } -} - -// Global overlayfs instance -export const overlayFS = new OverlayFS(); - -// Monkey patch fs.readFileSync to use our overlay -const originalReadFileSync = fs.readFileSync; -fs.readFileSync = function (filePath, options) { - if (overlayFS.exists(filePath)) { - const content = overlayFS.readFile(filePath); - if (options?.encoding) { - return content; - } - return Buffer.from(content); - } - return originalReadFileSync.call(this, filePath, options); -}; - -// Monkey patch fs.promises.readFile (async) to use our overlay -const originalAsyncReadFile = fsPromises.readFile; -fsPromises.readFile = function (filePath, options) { - if (overlayFS.exists(filePath)) { - const content = overlayFS.readFile(filePath); - if (options?.encoding) { - return Promise.resolve(content); - } - return Promise.resolve(Buffer.from(content)); - } - return originalAsyncReadFile.call(this, filePath, options); -}; - -// Monkey patch fs.access to handle overlayfs directories -const originalAccess = fs.access; -fs.access = function (filePath, mode, callback) { - // Handle directories that should exist in our overlay - if (filePath === '/var/lib/headplane/' || filePath === '/var/lib/headplane') { - if (callback) { - callback(null); - } else { - return Promise.resolve(); - } - return; - } - return originalAccess.call(this, filePath, mode, callback); -}; - -// Monkey patch fs.promises.access to handle overlayfs directories -const originalAsyncAccess = fsPromises.access; -fsPromises.access = function (filePath, mode) { - // Handle directories that should exist in our overlay - if (filePath === '/var/lib/headplane/' || filePath === '/var/lib/headplane') { - return Promise.resolve(); - } - return originalAsyncAccess.call(this, filePath, mode); -}; diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts deleted file mode 100644 index cc13f7f..0000000 --- a/tests/unit/config.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeEach, describe, expect, it } from 'vitest'; -import { stringify } from 'yaml'; -import { ConfigError, loadConfig } from '~/server/config/loader'; -import { HeadplaneConfig } from '~/server/config/schema'; -import { clearFakeFiles, createFakeFile } from './setup/overlay-fs'; - -async function writeTempFile(baseName: string, content: string) { - const dir = await mkdtemp(join(tmpdir(), 'headplane-test-')); - - const path = join(dir, baseName); - await writeFile(path, content); - return path; -} - -type RecursivePartial = { - [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P]; -}; - -function writeTempYamlConfig( - customConfig: RecursivePartial | string = {}, -) { - const defaultConfig = { - debug: false, - server: { - host: '127.0.0.1', - port: 8080, - data_path: '/var/lib/headplane', - cookie_secret: '12345678901234567890123456789012', - cookie_secure: false, - cookie_max_age: 86400, - }, - headscale: { url: 'http://localhost:8081', config_strict: false }, - } satisfies HeadplaneConfig; - - // biome-ignore lint/suspicious/noExplicitAny: I don't care - function deepMerge(target: any, source: any): any { - let output = { ...target }; - if (isObject(target) && isObject(source)) { - output = { ...target, ...source }; - for (const key of Object.keys(source)) { - const sourceValue = source[key as keyof typeof source]; - const targetValue = target[key as keyof typeof target]; - if (isObject(sourceValue) && key in target && isObject(targetValue)) { - output[key] = deepMerge(targetValue, sourceValue); - } else { - output[key] = source[key]; - } - } - for (const sectionName of ['oidc', 'server', 'headscale']) { - if (output[sectionName] && source[sectionName]) { - const sectionSource = source[sectionName]; - const sectionDefault = target[sectionName] || {}; - const sectionOutput = output[sectionName]; - - for (const baseKey of ['cookie_secret', 'client_secret', 'api_key']) { - const valueKey = baseKey; - const pathKey = `${baseKey}_path`; - const sourceHasValue = Object.hasOwn(sectionSource, valueKey); - const sourceHasPath = Object.hasOwn(sectionSource, pathKey); - const defaultHasValue = Object.hasOwn(sectionDefault, valueKey); - const defaultHasPath = Object.hasOwn(sectionDefault, pathKey); - - if (sourceHasPath && !sourceHasValue && defaultHasValue) { - delete sectionOutput[valueKey]; - } else if (sourceHasValue && !sourceHasPath && defaultHasPath) { - delete sectionOutput[pathKey]; - } - } - } - } - } - return output; - } - - // biome-ignore lint/suspicious/noExplicitAny: I don't care - function isObject(item: any): item is Record { - return item && typeof item === 'object' && !Array.isArray(item); - } - - const merged = deepMerge(defaultConfig, customConfig); - const yamlContent = - typeof customConfig === 'string' ? customConfig : stringify(merged); - return writeTempFile('config.yaml', yamlContent); -} - -// Store original process.env to restore after tests -const originalEnv = { ...process.env }; - -describe('Configuration Loading', () => { - beforeEach(() => { - delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH; - delete process.env.HEADPLANE_SERVER__COOKIE_SECRET_PATH; - delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH; - delete process.env.TEST_SECRET_DIR; - - clearFakeFiles(); - createFakeFile('/var/lib/headplane/agent_cache.json', JSON.stringify({})); - createFakeFile( - '/var/lib/headplane/users.json', - `[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`, - ); - createFakeFile('/tmp/agent_cache.json', JSON.stringify({})); - createFakeFile('irrelevant', 'irrelevant-content'); - createFakeFile('placeholder', 'placeholder-content'); - }); - - afterAll(() => { - Object.assign(process.env, originalEnv); - clearFakeFiles(); - }); - - describe('OIDC Configuration', () => { - const minimalOidcFields = { - token_endpoint_auth_method: 'client_secret_basic' as const, - disable_api_key_login: false, - headscale_api_key: 'dummyKey', - user_storage_file: '/var/lib/headplane/users.json', - profile_picture_source: 'oidc' as const, - strict_validation: true, - scope: 'openid email profile', - }; - - it('should load client_secret from file specified in client_secret_path', async () => { - const secretValue = 'yaml-file-oidc-secret'; - const secretPath = await writeTempFile('oidc_secret.txt', secretValue); - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - client_secret_path: secretPath, - ...minimalOidcFields, - }, - }); - const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc?.client_secret).toBe(secretValue); - }); - - it('should override YAML client_secret_path with environment variable', async () => { - const envValue = 'env-file-oidc-secret'; - const envPath = await writeTempFile('env_oidc_secret.txt', envValue); - process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = envPath; - // Instead of 'irrelevant', use a temp file that exists - const irrelevantPath = await writeTempFile( - 'irrelevant.txt', - 'irrelevant-content', - ); - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - client_secret_path: irrelevantPath, - ...minimalOidcFields, - }, - }); - const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); - expect(config.oidc?.client_secret).toBe(envValue); - }); - - it('should handle environment variable interpolation in client_secret_path', async () => { - const value = 'interpolated-secret'; - const dir = await mkdtemp(join(tmpdir(), 'headplane-secret-dir-')); - const filePath = join(dir, 'secret.txt'); - await writeFile(filePath, value); - - process.env.TEST_SECRET_DIR = dir; - process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = - // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation - '${TEST_SECRET_DIR}/secret.txt'; - // Instead of 'placeholder', use a temp file that exists - const placeholderPath = await writeTempFile( - 'placeholder.txt', - 'placeholder-content', - ); - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - client_secret_path: placeholderPath, - ...minimalOidcFields, - }, - }); - const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); - expect(config.oidc?.client_secret).toBe(value); - }); - - it('should reject when client_secret_path points to non-existent file', async () => { - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - client_secret_path: '/no/such/file', - ...minimalOidcFields, - }, - }); - await expect( - loadConfig({ loadEnv: false, path: tempConfigPath }), - ).rejects.toThrow(ConfigError); - }); - - it('should reject when client_secret_path has unresolvable env var interpolation', async () => { - process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = - // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation - '${MISSING_DIR}/secret.txt'; - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - client_secret_path: 'placeholder', - ...minimalOidcFields, - }, - }); - await expect( - loadConfig({ loadEnv: true, path: tempConfigPath }), - ).rejects.toThrow(/Environment variable "MISSING_DIR" not found/); - }); - }); - - describe('Server Configuration', () => { - it('should load cookie_secret directly from YAML', async () => { - const valid = 'abcdefghijklmnopqrstuvwxyz123456'; - const tempConfigPath = await writeTempYamlConfig({ - server: { cookie_secret: valid }, - }); - const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.server.cookie_secret).toBe(valid); - }); - - it('should load cookie_secret from file', async () => { - const secret = 'a'.repeat(32); - const secretPath = await writeTempFile('cookie_secret.txt', secret); - const tempConfigPath = await writeTempYamlConfig({ - server: { cookie_secret_path: secretPath }, - }); - const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.server.cookie_secret).toBe(secret); - }); - - it('should reject when both cookie_secret and cookie_secret_path are in YAML', async () => { - const secretPath = await writeTempFile('conflict.txt', 'x'.repeat(32)); - const tempConfigPath = await writeTempYamlConfig({ - server: { - cookie_secret: '1'.repeat(32), - cookie_secret_path: secretPath, - }, - }); - await expect( - loadConfig({ loadEnv: false, path: tempConfigPath }), - ).rejects.toThrow( - /Only one of "cookie_secret" or "cookie_secret_path" may be set/, - ); - }); - - it('should reject when neither cookie_secret nor cookie_secret_path is provided', async () => { - const yaml = ` -debug: false -server: - host: "127.0.0.1" - port: 8080 - cookie_secure: false - agent: - authkey: "key" - ttl: 180000 - cache_path: "/tmp/cache.json" -headscale: - url: "http://localhost" - config_strict: false -`; - const tempConfigPath = await writeTempYamlConfig(yaml); - await expect( - loadConfig({ loadEnv: false, path: tempConfigPath }), - ).rejects.toThrow( - /Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret/, - ); - }); - }); - - describe('Headscale Configuration', () => { - it('should load headscale_api_key directly from YAML', async () => { - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - headscale_api_key: 'hs-yaml-key', - token_endpoint_auth_method: 'client_secret_basic', - disable_api_key_login: false, - user_storage_file: '/var/lib/headplane/users.json', - profile_picture_source: 'oidc', - strict_validation: true, - scope: 'openid email profile', - }, - }); - const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc?.headscale_api_key).toBe('hs-yaml-key'); - }); - - it('should load headscale_api_key from file', async () => { - const val = 'hs-file-key'; - const p = await writeTempFile('hs_api_key.txt', val); - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - headscale_api_key_path: p, - token_endpoint_auth_method: 'client_secret_basic', - disable_api_key_login: false, - user_storage_file: '/var/lib/headplane/users.json', - profile_picture_source: 'oidc', - strict_validation: true, - scope: 'openid email profile', - }, - }); - const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); - expect(config.oidc?.headscale_api_key).toBe(val); - }); - - it('should reject when both headscale_api_key and headscale_api_key_path are in YAML', async () => { - const p = await writeTempFile('conflict.txt', 'irrelevant'); - const tempConfigPath = await writeTempYamlConfig({ - oidc: { - issuer: 'https://example.com/oidc', - client_id: 'test', - headscale_api_key: 'key', - headscale_api_key_path: p, - token_endpoint_auth_method: 'client_secret_basic', - disable_api_key_login: false, - user_storage_file: '/var/lib/headplane/users.json', - profile_picture_source: 'oidc', - strict_validation: true, - scope: 'openid email profile', - }, - }); - await expect( - loadConfig({ loadEnv: false, path: tempConfigPath }), - ).rejects.toThrow( - /Only one of "headscale_api_key" or "headscale_api_key_path" may be set/, - ); - }); - - it('should keep config_path string and interpolate env vars', async () => { - process.env.MY_HS_CONFIG_SUBDIR = 'hs-test'; - // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation - const cfgVal = '/etc/headscale-${MY_HS_CONFIG_SUBDIR}/config.yaml'; - const exp = '/etc/headscale-hs-test/config.yaml'; - const tempConfigPath = await writeTempYamlConfig({ - headscale: { config_path: cfgVal }, - }); - const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); - expect(config.headscale.config_path).toBe(exp); - }); - }); -}); diff --git a/tests/unit/config/config-file.test.ts b/tests/unit/config/config-file.test.ts new file mode 100644 index 0000000..0b9bc60 --- /dev/null +++ b/tests/unit/config/config-file.test.ts @@ -0,0 +1,81 @@ +import { dump } from 'js-yaml'; +import { beforeAll, describe, expect, test } from 'vitest'; +import { ConfigError } from '~/server/config/error'; +import { loadConfig, loadConfigFile } from '~/server/config/load'; +import { clearFakeFiles, createFakeFile } from '../setup/overlay-fs'; + +const writeYaml = (filePath: string, content: unknown) => { + const yamlContent = dump(content); + createFakeFile(filePath, yamlContent); +}; + +describe('Configuration YAML file loading', () => { + beforeAll(() => { + clearFakeFiles(); + }); + + test('should correctly parse different types from YAML file', async () => { + const filePath = '/config/test-config.yaml'; + writeYaml(filePath, { + headscale: { + url: 'http://localhost:8080', + }, + oidc: { + client_id: 'my-client-id', + }, + server: { + port: 8000, + }, + integration: { + agent: { + enabled: true, + }, + }, + }); + + const config = await loadConfigFile(filePath); + expect(config?.headscale?.url).toBe('http://localhost:8080'); + expect(config?.oidc?.client_id).toBe('my-client-id'); + expect(config?.server?.port).toBe(8000); + expect(config?.integration?.agent?.enabled).toBe(true); + }); + + test('should not throw errors for inaccessible file', async () => { + await expect( + loadConfigFile('/non-existent-path/config.yaml'), + ).resolves.toBeUndefined(); + }); + + test('should correctly get a finalized config from YAML', async () => { + const filePath = '/config/minimal-config.yaml'; + writeYaml(filePath, { + headscale: { + url: 'http://localhost:8080', + }, + server: { + cookie_secret: 'thirtytwo-character-cookiesecret', + }, + }); + + const config = await loadConfig(filePath); + expect(config.headscale.url).toBe('http://localhost:8080'); + expect(config.server.cookie_secret).toBe( + 'thirtytwo-character-cookiesecret', + ); + }); + + test('should throw error for missing required fields', async () => { + const filePath = '/config/invalid-config.yaml'; + writeYaml(filePath, { + server: { + port: 8000, + }, + }); + + await expect(loadConfig(filePath)).rejects.toEqual( + expect.objectContaining( + ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }), + ), + ); + }); +}); diff --git a/tests/unit/config/env-vars.test.ts b/tests/unit/config/env-vars.test.ts new file mode 100644 index 0000000..3e56f89 --- /dev/null +++ b/tests/unit/config/env-vars.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { ConfigError } from '~/server/config/error'; +import { loadConfig, loadConfigEnv } from '~/server/config/load'; + +const envVarSnapshot = { ...process.env }; +describe('Configuration environment variable handling', () => { + beforeEach(() => { + process.env = { ...envVarSnapshot }; + }); + + test('should correctly parse different types from env vars', async () => { + process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; + process.env.HEADPLANE_OIDC__CLIENT_ID = 'my-client-id'; + process.env.HEADPLANE_SERVER__PORT = '8000'; + process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = 'true'; + + const config = await loadConfigEnv(); + expect(config?.headscale?.url).toBe('http://localhost:8080'); + expect(config?.oidc?.client_id).toBe('my-client-id'); + expect(config?.server?.port).toBe(8000); + expect(config?.integration?.agent?.enabled).toBe(true); + }); + + test('should not load env vars without the HEADPLANE_ prefix', async () => { + process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; + process.env.OTHER_PREFIX_OIDC__CLIENT_ID = 'should-not-be-loaded'; + + const config = await loadConfigEnv(); + expect(config?.headscale?.url).toBe('http://localhost:8080'); + expect(config?.oidc?.client_id).toBeUndefined(); + }); + + test('should correctly get a finalized config from env vars', async () => { + process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; + process.env.HEADPLANE_SERVER__COOKIE_SECRET = + 'thirtytwo-character-cookiesecret'; + + const config = await loadConfig('./non-existent-path.yaml'); + expect(config.headscale.url).toBe('http://localhost:8080'); + expect(config.server.cookie_secret).toBe( + 'thirtytwo-character-cookiesecret', + ); + }); + + test('should throw error for missing required fields', async () => { + process.env.HEADPLANE_SERVER__PORT = '8000'; + + await expect(loadConfig('./non-existent-path.yaml')).rejects.toEqual( + expect.objectContaining( + ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }), + ), + ); + }); +}); diff --git a/tests/unit/config/secret-path.test.ts b/tests/unit/config/secret-path.test.ts new file mode 100644 index 0000000..0e5cbe3 --- /dev/null +++ b/tests/unit/config/secret-path.test.ts @@ -0,0 +1,83 @@ +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'; +import { createFakeFile } from '../setup/overlay-fs'; + +describe('Configuration secret path handling', () => { + test('should correctly substitute server.cookie_secret', async () => { + createFakeFile('/secrets/cookie_secret.txt', 'supersecretcookievalue'); + const config = { + server: { + cookie_secret_path: '/secrets/cookie_secret.txt', + }, + } as PartialHeadplaneConfigWithPaths; + + await loadConfigKeyPaths(config); + expect(config.server?.cookie_secret).toBe('supersecretcookievalue'); + }); + + test('should throw error for missing secret file', async () => { + const config = { + server: { + cookie_secret_path: '/secrets/missing_cookie_secret.txt', + }, + } as PartialHeadplaneConfigWithPaths; + + await expect(loadConfigKeyPaths(config)).rejects.toMatchObject( + ConfigError.from('MISSING_SECRET_FILE', { + pathKey: 'server.cookie_secret_path', + filePath: '/secrets/missing_cookie_secret.txt', + }), + ); + }); + + test('should throw error for conflicting secret path and field', async () => { + const config = { + server: { + cookie_secret: 'explicitsecretvalue', + cookie_secret_path: '/secrets/cookie_secret.txt', + }, + } as PartialHeadplaneConfigWithPaths; + + await expect(loadConfigKeyPaths(config)).rejects.toMatchObject( + ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', { + fieldName: 'server.cookie_secret', + }), + ); + }); + + test('should correctly interpolate env vars in secret paths', async () => { + process.env.HP_TEST_COOKIE_SECRET_FILE = 'cookie_secret.txt'; + createFakeFile( + `/secrets/${process.env.HP_TEST_COOKIE_SECRET_FILE}`, + 'envvarsecretvalue', + ); + + const config = { + server: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation + cookie_secret_path: '/secrets/${HP_TEST_COOKIE_SECRET_FILE}', + }, + } as PartialHeadplaneConfigWithPaths; + + await loadConfigKeyPaths(config); + expect(config.server?.cookie_secret).toBe('envvarsecretvalue'); + }); + + test('should throw error for missing interpolated env var in secret path', async () => { + const config = { + server: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation + cookie_secret_path: '/secrets/${MISSING_ENV_VAR}', + }, + } as PartialHeadplaneConfigWithPaths; + + await expect(loadConfigKeyPaths(config)).rejects.toMatchObject( + ConfigError.from('MISSING_INTERPOLATION_VARIABLE', { + pathKey: 'server.cookie_secret_path', + variableName: 'MISSING_ENV_VAR', + }), + ); + }); +}); diff --git a/tests/unit/setup/overlay-fs.ts b/tests/unit/setup/overlay-fs.ts index 2ea667e..b495ee8 100644 --- a/tests/unit/setup/overlay-fs.ts +++ b/tests/unit/setup/overlay-fs.ts @@ -30,7 +30,7 @@ vi.mock(import('node:fs/promises'), async (importOrig) => { access: (path, mode) => { const p = path.toString(); - if (p === '/var/lib/headplane/' || p === '/var/lib/headplane') { + if (fakeFs.has(p)) { return Promise.resolve(); } diff --git a/vitest.config.ts b/vitest.config.ts index b38280c..d892bf8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ extends: true, test: { name: 'unit', - include: ['tests/unit/*.test.ts'], + include: ['tests/unit/**/*.test.ts'], setupFiles: ['tests/unit/setup/overlay-fs.ts'], }, },