diff --git a/.gitignore b/.gitignore index 0e2cf27..6368630 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules .env app/hp_ssh.wasm app/wasm_exec.js + +/.direnv diff --git a/app/server/config/loader.ts b/app/server/config/loader.ts index 699479a..3dee4f3 100644 --- a/app/server/config/loader.ts +++ b/app/server/config/loader.ts @@ -1,5 +1,5 @@ import { constants, access, readFile } from 'node:fs/promises'; -import { env, exit } from 'node:process'; +import { env } from 'node:process'; import { type } from 'arktype'; import { configDotenv } from 'dotenv'; import { parseDocument } from 'yaml'; @@ -11,6 +11,28 @@ import { 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 + */ +function interpolateEnvVars(str: string): string { + return str.replace(/\$\{([^}]+)\}/g, (match, 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. // @@ -18,37 +40,83 @@ import { // 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); - const valid = await validateConfigPath(path); - if (!valid) { - exit(1); - } + await validateConfigPath(path); const data = await loadConfigFile(path); if (!data) { - exit(1); + throw new ConfigError('Failed to load configuration file'); } let config = validateConfig({ ...data, debug: log.debugEnabled }); - if (!config) { - exit(1); - } if (!loadEnv) { log.debug('config', 'Environment variable overrides are disabled'); log.debug('config', 'This also disables the loading of a .env file'); + config = await loadSecretsFromFiles(config); + log.debug('config', 'Loaded file-based secrets'); return config; } log.info('config', 'Loading a .env file (if available)'); configDotenv({ override: true }); - config = coalesceEnv(config); - if (!config) { - exit(1); + 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, + ); } + config = await loadSecretsFromFiles(config); + log.debug('config', 'Loaded file-based secrets'); + return config; } +/** + * 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 = new Set([ + 'pre_authkey_path', + 'client_secret_path', + 'headscale_api_key_path', + 'cookie_secret_path', +]); +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_KEYS.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 T; +} + export async function hp_loadConfig() { // // OIDC Related Checks // if (config.oidc) { @@ -76,7 +144,9 @@ async function validateConfigPath(path: string) { } catch (error) { log.error('config', 'Unable to read a configuration file at %s', path); log.error('config', '%s', error); - return false; + throw new ConfigError( + `Unable to read configuration file at ${path}: ${error}`, + ); } } @@ -91,7 +161,7 @@ async function loadConfigFile(path: string): Promise { log.error('config', ` - ${error.toString()}`); } - return false; + throw new ConfigError(`Cannot parse configuration file at ${path}`); } if (configYaml.warnings.length > 0) { @@ -109,7 +179,7 @@ async function loadConfigFile(path: string): Promise { } catch (e) { log.error('config', 'Error reading configuration file at %s', path); log.error('config', '%s', e); - return false; + throw new ConfigError(`Error reading configuration file at ${path}: ${e}`); } } @@ -117,14 +187,14 @@ export function validateConfig(config: unknown) { log.debug('config', 'Validating Headplane configuration'); const result = headplaneConfig(config); if (result instanceof type.errors) { - log.error('config', 'Error validating Headplane configuration:'); + const errorMessages = []; for (const [number, error] of result.entries()) { - log.error('config', ` - (${number}): ${error.toString()}`); + const errorMsg = error.toString(); + log.error('config', ` - (${number}): ${errorMsg}`); + errorMessages.push(errorMsg); } - - return; + throw new ConfigError(errorMessages.join('\n')); } - return result; } diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index deb6b50..c1a9be6 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -20,8 +20,35 @@ 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: '(32 <= string <= 32)?', + cookie_secret_path: 'string?', cookie_secure: stringToBool, +}) + .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(), }); const oidcConfig = type({ @@ -34,9 +61,41 @@ const oidcConfig = type({ 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: 'string?', + headscale_api_key_path: 'string?', strict_validation: stringToBool.default(true), -}).onDeepUndeclaredKey('reject'); +}) + .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 = "/var/lib/headplane/users.json"', + disable_api_key_login: stringToBool.optional(), + headscale_api_key: 'string?', + headscale_api_key_path: 'string?', + strict_validation: stringToBool.default(true), +}); const headscaleConfig = type({ url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), @@ -47,26 +106,53 @@ const headscaleConfig = type({ 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: '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 partialAgentConfig = type({ - enabled: stringToBool, - host_name: 'string | undefined', - pre_authkey: 'string | undefined', - cache_ttl: 'number.integer | undefined', - cache_path: 'string | undefined', - executable_path: 'string | undefined', - work_dir: 'string | undefined', -}).partial(); - const dockerConfig = type({ enabled: stringToBool, container_name: 'string = ""', @@ -115,10 +201,10 @@ export const headplaneConfig = type({ export const partialHeadplaneConfig = type({ debug: stringToBool, - server: serverConfig.partial(), - 'oidc?': oidcConfig.partial(), + server: partialServerConfig, + 'oidc?': partialOidcConfig, 'integration?': partialIntegrationConfig, - headscale: headscaleConfig.partial(), + headscale: partialHeadscaleConfig, }).partial(); export type HeadplaneConfig = typeof headplaneConfig.infer; diff --git a/biome.json b/biome.json index 302a432..dfae710 100644 --- a/biome.json +++ b/biome.json @@ -28,6 +28,9 @@ }, "correctness": { "useExhaustiveDependencies": "off" + }, + "suspicious": { + "noExplicitAny": "warn" } } }, diff --git a/flake.nix b/flake.nix index 9229532..afe0e4f 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,8 @@ rec { in rec { formatter = pkgs.alejandra; packages = { - headplane = pkgs.callPackage ./nix/package.nix {}; + hp_ssh_wasm = pkgs.callPackage ./nix/wasm.nix {}; + headplane = pkgs.callPackage ./nix/package.nix { hp_ssh_wasm = packages.hp_ssh_wasm; }; headplane-agent = pkgs.callPackage ./nix/agent.nix {}; }; checks.default = pkgs.symlinkJoin { @@ -61,7 +62,8 @@ rec { }) // { overlays.default = final: prev: { - headplane = final.callPackage ./nix/package.nix {}; + hp_ssh_wasm = final.callPackage ./nix/wasm.nix {}; + headplane = final.callPackage ./nix/package.nix { hp_ssh_wasm = final.hp_ssh_wasm; }; headplane-agent = final.callPackage ./nix/agent.nix {}; }; nixosModules.headplane = import ./nix/module.nix; diff --git a/nix/package.nix b/nix/package.nix index b43a7cc..2208f51 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -5,12 +5,19 @@ nodejs_22, pnpm_10, stdenv, + hp_ssh_wasm, ... }: -stdenv.mkDerivation (finalAttrs: { - pname = "headplane"; - version = (builtins.fromJSON (builtins.readFile ../package.json)).version; +let + pkg = builtins.fromJSON (builtins.readFile ../package.json); + pname = pkg.name; + version = pkg.version; src = ../.; +in +stdenv.mkDerivation (finalAttrs: { + pname = pname; + version = version; + src = src; nativeBuildInputs = [ makeWrapper @@ -22,12 +29,14 @@ stdenv.mkDerivation (finalAttrs: { dontCheckForBrokenSymlinks = true; pnpmDeps = pnpm_10.fetchDeps { - inherit (finalAttrs) pname version src; - hash = "sha256-hu3028V/EWimYB1TGn7g06kJRIpZA6cuOIjPMEc8ddw="; + inherit pname version src; + hash = "sha256-GNSpFqPobX6MDPUXxz2XwdZ2Wt7boN8aok52pGgpGoM="; }; buildPhase = '' runHook preBuild + cp ${hp_ssh_wasm}/hp_ssh.wasm app/hp_ssh.wasm + cp ${hp_ssh_wasm}/wasm_exec.js app/wasm_exec.js pnpm build runHook postBuild ''; @@ -36,10 +45,12 @@ stdenv.mkDerivation (finalAttrs: { runHook preInstall mkdir -p $out/{bin,share/headplane} cp -r build $out/share/headplane/ + cp -r node_modules $out/share/headplane/ + cp -r drizzle $out/share/headplane/ sed -i "s;$PWD;../..;" $out/share/headplane/build/server/index.js makeWrapper ${lib.getExe nodejs_22} $out/bin/headplane \ - --chdir $out/share/headplane \ - --add-flags $out/share/headplane/build/server/index.js + --chdir $out/share/headplane \ + --add-flags $out/share/headplane/build/server/index.js runHook postInstall ''; }) diff --git a/nix/wasm.nix b/nix/wasm.nix new file mode 100644 index 0000000..e53b37e --- /dev/null +++ b/nix/wasm.nix @@ -0,0 +1,35 @@ +{ buildGoModule, go, lib, ... }: + +let + pkg = builtins.fromJSON (builtins.readFile ../package.json); + pname = "hp_ssh-wasm"; + version = pkg.version; + wasmExecJs = + if builtins.pathExists "${go}/share/go/lib/wasm/wasm_exec.js" then + "${go}/share/go/lib/wasm/wasm_exec.js" + else if builtins.pathExists "${go}/lib/wasm/wasm_exec.js" then + "${go}/lib/wasm/wasm_exec.js" + else + "${go}/share/go/misc/wasm/wasm_exec.js"; +in +buildGoModule rec { + inherit pname version; + src = ../.; + subPackages = [ "cmd/hp_ssh" ]; + vendorHash = "sha256-3hZzDORAH+D4FW6SkOv3Enddd+q36ZALryvCPD9E5Ac="; + env.CGO_ENABLED = 0; + + nativeBuildInputs = [ go ]; + + buildPhase = '' + export GOOS=js + export GOARCH=wasm + go build -o hp_ssh.wasm ./cmd/hp_ssh + ''; + + installPhase = '' + mkdir -p $out + cp hp_ssh.wasm $out/ + cp ${wasmExecJs} $out/wasm_exec.js + ''; +} diff --git a/package.json b/package.json index 235775a..ffc8546 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "react-router build", "dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev", "start": "node build/server/index.js", - "typecheck": "tsc" + "typecheck": "tsc", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -63,6 +64,7 @@ "@biomejs/biome": "^1.9.4", "@react-router/dev": "^7.6.1", "@tailwindcss/vite": "^4.1.8", + "@types/node": "^22.15.17", "@types/websocket": "^1.0.10", "drizzle-kit": "^0.31.1", "lefthook": "^1.11.13", @@ -74,7 +76,9 @@ "tailwindcss-react-aria-components": "^2.0.0", "typescript": "^5.8.3", "vite": "^6.3.5", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "js-yaml": "^4.1.0", + "vitest": "^3.1.3" }, "packageManager": "pnpm@10.4.0", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 002e709..21dd17d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,12 +161,18 @@ importers: '@tailwindcss/vite': specifier: ^4.1.8 version: 4.1.8(vite@6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0)) + '@types/node': + specifier: ^22.15.17 + version: 22.15.24 '@types/websocket': specifier: ^1.0.10 version: 1.0.10 drizzle-kit: specifier: ^0.31.1 version: 0.31.1 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 lefthook: specifier: ^1.11.13 version: 1.11.13 @@ -197,6 +203,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0)) + vitest: + specifier: ^3.1.3 + version: 3.2.4(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0) packages: @@ -1754,6 +1763,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1769,9 +1784,6 @@ packages: '@types/node@20.17.52': resolution: {integrity: sha512-2aj++KfxubvW/Lc0YyXE3OEW7Es8TWn1MsRzYgcOGyTNQxi0L8rxQUCZ7ZbyOBWZQD5I63PV9egZWMsapVaklg==} - '@types/node@22.10.7': - resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} - '@types/node@22.15.24': resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==} @@ -1832,6 +1844,35 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@xterm/addon-clipboard@0.1.0': resolution: {integrity: sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==} peerDependencies: @@ -1894,6 +1935,10 @@ packages: arktype@2.1.20: resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1982,6 +2027,14 @@ packages: caniuse-lite@1.0.30001718: resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} + engines: {node: '>=18'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2068,6 +2121,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2262,6 +2319,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -2424,6 +2485,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2589,6 +2653,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + lru-cache@10.2.2: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} @@ -2739,6 +2806,13 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2990,6 +3064,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3041,6 +3118,12 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stream-buffers@3.0.3: resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} engines: {node: '>= 0.10.0'} @@ -3074,6 +3157,9 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} @@ -3122,10 +3208,28 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3165,9 +3269,6 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3231,6 +3332,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -3279,6 +3385,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -3305,6 +3439,11 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5287,6 +5426,12 @@ snapshots: '@types/node': 22.15.24 optional: true + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.6': {} '@types/estree@1.0.7': {} @@ -5302,10 +5447,6 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/node@22.10.7': - dependencies: - undici-types: 6.20.0 - '@types/node@22.15.24': dependencies: undici-types: 6.21.0 @@ -5328,7 +5469,7 @@ snapshots: '@types/websocket@1.0.10': dependencies: - '@types/node': 22.10.7 + '@types/node': 22.15.24 '@types/ws@8.18.1': dependencies: @@ -5383,6 +5524,48 @@ snapshots: - '@codemirror/lint' - '@codemirror/search' + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + '@xterm/addon-clipboard@0.1.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 @@ -5430,6 +5613,8 @@ snapshots: '@ark/schema': 0.46.0 '@ark/util': 0.46.0 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} b4a@1.6.7: {} @@ -5531,6 +5716,16 @@ snapshots: caniuse-lite@1.0.30001718: {} + chai@5.2.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.4 + pathval: 2.0.1 + + check-error@2.1.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5600,6 +5795,8 @@ snapshots: dedent@1.6.0: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: optional: true @@ -5741,6 +5938,8 @@ snapshots: expand-template@2.0.3: optional: true + expect-type@1.2.2: {} + fast-fifo@1.3.2: {} fdir@6.4.5(picomatch@4.0.2): @@ -5899,6 +6098,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -6034,6 +6235,8 @@ snapshots: lodash@4.17.21: {} + loupe@3.1.4: {} + lru-cache@10.2.2: {} lru-cache@5.1.1: @@ -6162,6 +6365,10 @@ snapshots: pathe@1.1.2: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.2: {} @@ -6464,6 +6671,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -6518,6 +6727,10 @@ snapshots: sprintf-js@1.1.3: {} + stackback@0.0.2: {} + + std-env@3.9.0: {} + stream-buffers@3.0.3: {} stream-slice@0.1.2: {} @@ -6557,6 +6770,10 @@ snapshots: strip-json-comments@2.0.1: optional: true + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + style-mod@4.1.2: {} tailwind-merge@3.3.0: {} @@ -6627,11 +6844,21 @@ snapshots: dependencies: b4a: 1.6.7 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.5(picomatch@4.0.2) picomatch: 4.0.2 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + tr46@0.0.3: {} tsconfck@3.1.4(typescript@5.8.3): @@ -6660,8 +6887,6 @@ snapshots: undici-types@6.19.8: {} - undici-types@6.20.0: {} - undici-types@6.21.0: {} undici@6.21.3: {} @@ -6731,6 +6956,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0)): dependencies: debug: 4.4.0 @@ -6759,6 +7005,47 @@ snapshots: tsx: 4.19.4 yaml: 2.8.0 + vitest@3.2.4(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@22.15.24)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.19.4)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.24 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-keyname@2.2.8: {} web-streams-polyfill@3.3.3: {} @@ -6781,6 +7068,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..5ee35a4 --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,353 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { overlayFS } from './setupOverlayFs.js'; + +// Get the directory name in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Helper to create a temp file with given content, return its path +function writeTempFile(baseName, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'headplane-test-')); + const filePath = path.join(dir, baseName); + fs.writeFileSync(filePath, content); + return filePath; +} + +// Helper to create a temporary YAML config file with minimal required structure +function writeTempYamlConfig(customConfig = {}) { + const defaultConfig = { + debug: false, + server: { + host: '127.0.0.1', + port: 8080, + cookie_secret: '12345678901234567890123456789012', + cookie_secure: false, + agent: { + authkey: 'testagentauthkey', + ttl: 180000, + cache_path: '/tmp/agent_cache.json', + }, + }, + headscale: { url: 'http://localhost:8081', config_strict: false }, + }; + + function deepMerge(target, source) { + let output = { ...target }; + if (isObject(target) && isObject(source)) { + output = { ...target, ...source }; + for (const key of Object.keys(source)) { + if (isObject(source[key]) && key in target && isObject(target[key])) { + output[key] = deepMerge(target[key], source[key]); + } 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; + } + + function isObject(item) { + return item && typeof item === 'object' && !Array.isArray(item); + } + + const merged = deepMerge(defaultConfig, customConfig); + const yamlContent = + typeof customConfig === 'string' ? customConfig : yaml.dump(merged); + return writeTempFile('config.yaml', yamlContent); +} + +// Store original process.env to restore after tests +const originalEnv = { ...process.env }; + +describe('Configuration Loading', () => { + let loadConfig; + let ConfigError; + + beforeAll(async () => { + const module = await import('../app/server/config/loader.js'); + loadConfig = module.loadConfig; + ConfigError = module.ConfigError; + }); + + beforeEach(() => { + // biome-ignore lint/performance/noDelete: needed for test cleanup + delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH; + // biome-ignore lint/performance/noDelete: needed for test cleanup + delete process.env.HEADPLANE_SERVER__COOKIE_SECRET_PATH; + // biome-ignore lint/performance/noDelete: needed for test cleanup + delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH; + // biome-ignore lint/performance/noDelete: needed for test cleanup + delete process.env.TEST_SECRET_DIR; + + // Clear overlayfs + overlayFS.clear(); + + // Create default files that the config expects + overlayFS.createFile( + '/var/lib/headplane/agent_cache.json', + JSON.stringify({}), + ); + overlayFS.createFile( + '/var/lib/headplane/users.json', + `[{"u":"acb3294f89a16b554e06b80d5266a3c8b09a883e1fa78ac459a550bf52a32564","c":65535}]`, + ); + overlayFS.createFile('/tmp/agent_cache.json', JSON.stringify({})); + + // Create test files that some tests expect to exist + overlayFS.createFile('irrelevant', 'irrelevant-content'); + overlayFS.createFile('placeholder', 'placeholder-content'); + }); + + afterAll(() => { + Object.assign(process.env, originalEnv); + overlayFS.clear(); + }); + + describe('OIDC Configuration', () => { + const minimalOidcFields = { + token_endpoint_auth_method: 'client_secret_basic', + disable_api_key_login: false, + headscale_api_key: 'dummyKey', + }; + + it('should load client_secret from file specified in client_secret_path', async () => { + const secretValue = 'yaml-file-oidc-secret'; + const secretPath = writeTempFile('oidc_secret.txt', secretValue); + const tempConfigPath = 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 = 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 = writeTempFile( + 'irrelevant.txt', + 'irrelevant-content', + ); + const tempConfigPath = 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 = fs.mkdtempSync( + path.join(os.tmpdir(), 'headplane-secret-dir-'), + ); + const file = path.join(dir, 'secret.txt'); + fs.writeFileSync(file, value); + process.env.TEST_SECRET_DIR = dir; + process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = + '${TEST_SECRET_DIR}/secret.txt'; + // Instead of 'placeholder', use a temp file that exists + const placeholderPath = writeTempFile( + 'placeholder.txt', + 'placeholder-content', + ); + const tempConfigPath = 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 = 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 = + '${MISSING_DIR}/secret.txt'; + const tempConfigPath = 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 = 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 = writeTempFile('cookie_secret.txt', secret); + const tempConfigPath = 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 = writeTempFile('conflict.txt', 'x'.repeat(32)); + const tempConfigPath = 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 = 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 = 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, + }, + }); + 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 = writeTempFile('hs_api_key.txt', val); + const tempConfigPath = 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, + }, + }); + 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 = writeTempFile('conflict.txt', 'irrelevant'); + const tempConfigPath = 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, + }, + }); + 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'; + const cfgVal = '/etc/headscale-${MY_HS_CONFIG_SUBDIR}/config.yaml'; + const exp = '/etc/headscale-hs-test/config.yaml'; + const tempConfigPath = writeTempYamlConfig({ + headscale: { config_path: cfgVal }, + }); + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + expect(config.headscale.config_path).toBe(exp); + }); + }); +}); diff --git a/tests/setupOverlayFs.js b/tests/setupOverlayFs.js new file mode 100644 index 0000000..439f02f --- /dev/null +++ b/tests/setupOverlayFs.js @@ -0,0 +1,86 @@ +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/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a77f53c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.js'], + exclude: ['node_modules/**', 'build/**'], + setupFiles: ['./tests/setupOverlayFs.js'], + }, + resolve: { + alias: { + '~': resolve(__dirname, './app'), + }, + }, +});