diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index 81f3a04..cb664b0 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -1,18 +1,9 @@ -import { constants, access, readFile } from 'node:fs/promises'; +import { constants, access, readFile, writeFile } from 'node:fs/promises'; import { type } from 'arktype'; -import { parseDocument } from 'yaml'; +import { Document, parseDocument } from 'yaml'; import log from '~/utils/log'; import { headscaleConfig } from './config-schema'; -interface ConfigModeAvailable { - access: 'rw' | 'ro'; - // TODO: More attributes -} - -interface ConfigModeUnavailable { - access: 'no'; -} - interface PatchConfig { path: string; value: unknown; @@ -23,14 +14,20 @@ interface PatchConfig { // patch it and to query it for its mode class HeadscaleConfig { private config?: typeof headscaleConfig.infer; + private document?: Document; private access: 'rw' | 'ro' | 'no'; + private path?: string; constructor( access: 'rw' | 'ro' | 'no', config?: typeof headscaleConfig.infer, + document?: Document, + path?: string, ) { this.access = access; this.config = config; + this.document = document; + this.path = path; } readable() { @@ -45,8 +42,66 @@ class HeadscaleConfig { return this.config; } - // TODO: Implement patching async patch(patches: PatchConfig[]) { + if (!this.path || !this.document || !this.readable() || !this.writable()) { + return; + } + + log.debug('config', 'Patching Headscale configuration'); + for (const patch of patches) { + const { path, value } = patch; + log.debug('config', 'Patching %s with %o', path, value); + + // If the key is something like `test.bar."foo.bar"`, then we treat + // the foo.bar as a single key, and not as two keys, so that needs + // to be split correctly. + + // Iterate through each character, and if we find a dot, we check if + // the next character is a quote, and if it is, we skip until the next + // quote, and then we skip the next character, which should be a dot. + // If it's not a quote, we split it. + const key = []; + let current = ''; + let quote = false; + + for (const char of path) { + if (char === '"') { + quote = !quote; + } + + if (char === '.' && !quote) { + key.push(current); + current = ''; + continue; + } + + current += char; + } + + key.push(current.replaceAll('"', '')); + if (value === null) { + this.document.deleteIn(key); + continue; + } + + this.document.setIn(key, value); + } + + // Revalidate our configuration and update the config + // object with the new configuration + log.info('config', 'Revalidating Headscale configuration'); + const config = validateConfig(this.document.toJSON()); + if (!config) { + return; + } + + log.debug( + 'config', + 'Writing updated Headscale configuration to %s', + this.path, + ); + await writeFile(this.path, this.document.toString(), 'utf8'); + this.config = config; return; } } @@ -63,21 +118,26 @@ export async function loadHeadscaleConfig(path?: string, strict = true) { return new HeadscaleConfig('no'); } - const data = await loadConfigFile(path); - if (!data) { + const document = await loadConfigFile(path); + if (!document) { return new HeadscaleConfig('no'); } if (!strict) { - return new HeadscaleConfig(w ? 'rw' : 'ro', augmentUnstrictConfig(data)); + return new HeadscaleConfig( + w ? 'rw' : 'ro', + augmentUnstrictConfig(document.toJSON()), + document, + path, + ); } - const config = validateConfig(data); + const config = validateConfig(document.toJSON()); if (!config) { return new HeadscaleConfig('no'); } - return new HeadscaleConfig(w ? 'rw' : 'ro', config); + return new HeadscaleConfig(w ? 'rw' : 'ro', config, document, path); } async function validateConfigPath(path: string) { @@ -111,7 +171,7 @@ async function validateConfigPath(path: string) { } } -async function loadConfigFile(path: string): Promise { +async function loadConfigFile(path: string) { log.debug('config', 'Reading Headscale configuration file at %s', path); try { const data = await readFile(path, 'utf8'); @@ -129,7 +189,7 @@ async function loadConfigFile(path: string): Promise { return false; } - return configYaml.toJSON() as unknown; + return configYaml; } catch (e) { log.error( 'config', diff --git a/app/utils/config/loader.ts b/app/utils/config/loader.ts deleted file mode 100644 index b2cf131..0000000 --- a/app/utils/config/loader.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { constants, access, readFile, writeFile } from 'node:fs/promises'; -import { Document, parseDocument } from 'yaml'; -import { hp_getIntegration } from '~/utils/integration/loader'; -import mutex from '~/utils/mutex'; -import { hp_getConfig } from '~server/context/global'; -import log from '~server/utils/log'; -import { HeadscaleConfig, validateConfig } from './parser'; - -let runtimeYaml: Document | undefined = undefined; -let runtimeConfig: HeadscaleConfig | undefined = undefined; -let runtimePath: string | undefined = undefined; -let runtimeMode: 'rw' | 'ro' | 'no' = 'no'; -const runtimeLock = mutex(); - -export type ConfigModes = - | { - mode: 'rw' | 'ro'; - config: HeadscaleConfig; - } - | { - mode: 'no'; - config: undefined; - }; - -// export function hs_getConfig(): ConfigModes { -// return { -// mode: 'no', -// config: undefined, -// }; - -// if (runtimeMode === 'no') { -// return { -// mode: 'no', -// config: undefined, -// }; -// } - -// runtimeLock.acquire(); -// // We can assert if mode is not 'no' -// const config = runtimeConfig!; -// runtimeLock.release(); - -// return { -// mode: runtimeMode, -// config: config, -// }; -// } - -export async function hs_loadConfig(path?: string, strict?: boolean) { - if (runtimeConfig !== undefined) { - return; - } - - runtimeLock.acquire(); - if (!path) { - runtimeLock.release(); - return; - } - - runtimeMode = await validateConfigPath(path); - if (runtimeMode === 'no') { - runtimeLock.release(); - return; - } - - runtimePath = path; - const rawConfig = await loadConfigFile(path); - if (!rawConfig) { - return; - } - - const config = validateConfig(rawConfig, strict ?? true); - if (!config) { - runtimeMode = 'no'; - } - - runtimeConfig = config; -} - -async function validateConfigPath(path: string) { - log.debug('CFGX', `Validating Headscale configuration file at ${path}`); - try { - await access(path, constants.F_OK | constants.R_OK); - log.info('CFGX', `Headscale configuration found at ${path}`); - } catch (e) { - log.error('CFGX', `Headscale configuration not readable at ${path}`); - log.error('CFGX', `${e}`); - return 'no'; - } - - let writeable = false; - try { - await access(path, constants.W_OK); - writeable = true; - } catch (e) { - log.warn('CFGX', `Headscale configuration not writeable at ${path}`); - log.debug('CFGX', `${e}`); - } - - return writeable ? 'rw' : 'ro'; -} - -async function loadConfigFile(path: string) { - log.debug('CFGX', `Loading Headscale configuration file at ${path}`); - try { - const data = await readFile(path, 'utf8'); - const configYaml = parseDocument(data); - - if (configYaml.errors.length > 0) { - log.error( - 'CFGX', - `Error parsing Headscale configuration file at ${path}`, - ); - for (const error of configYaml.errors) { - log.error('CFGX', ` ${error.toString()}`); - } - - return; - } - - runtimeYaml = configYaml; - return configYaml.toJSON() as unknown; - } catch (e) { - log.error('CFGX', `Error reading Headscale configuration file at ${path}`); - log.error('CFGX', `${e}`); - return; - } -} - -type PatchConfig = { path: string; value: unknown }; -export async function hs_patchConfig(patches: PatchConfig[]) { - if (!runtimeConfig || !runtimeYaml || !runtimePath) { - log.error('CFGX', 'Headscale configuration not loaded'); - return; - } - - if (runtimeMode === 'no') { - return; - } - - if (runtimeMode === 'ro') { - throw new Error('Headscale configuration is read-only'); - } - - runtimeLock.acquire(); - const config = runtimeConfig!; - - log.debug('CFGX', 'Patching Headscale configuration'); - for (const patch of patches) { - const { path, value } = patch; - log.debug('CFGX', 'Patching %s in Headscale configuration', path); - // If the key is something like `test.bar."foo.bar"`, then we treat - // the foo.bar as a single key, and not as two keys, so that needs - // to be split correctly. - - // Iterate through each character, and if we find a dot, we check if - // the next character is a quote, and if it is, we skip until the next - // quote, and then we skip the next character, which should be a dot. - // If it's not a quote, we split it. - const key = []; - let current = ''; - let quote = false; - - for (const char of path) { - if (char === '"') { - quote = !quote; - } - - if (char === '.' && !quote) { - key.push(current); - current = ''; - continue; - } - - current += char; - } - - key.push(current.replaceAll('"', '')); - - // Deletion handling - if (value === null) { - runtimeYaml.deleteIn(key); - continue; - } - - runtimeYaml.setIn(key, value); - } - - // Revalidate the configuration - const context = hp_getConfig(); - const newRawConfig = runtimeYaml.toJSON() as unknown; - runtimeConfig = context.headscale.config_strict - ? validateConfig(newRawConfig, true) - : (newRawConfig as HeadscaleConfig); - - log.debug( - 'CFGX', - 'Writing patched Headscale configuration to %s', - runtimePath, - ); - await writeFile(runtimePath, runtimeYaml.toString(), 'utf8'); - runtimeLock.release(); -} - -// IMPORTANT THIS IS A SIDE EFFECT ON INIT -// TODO: Replace this into the new singleton system -// const context = hp_getConfig(); -// hs_loadConfig(context.headscale.config_path, context.headscale.config_strict); -// hp_getIntegration(); diff --git a/app/utils/config/parser.ts b/app/utils/config/parser.ts deleted file mode 100644 index 4f6a9c7..0000000 --- a/app/utils/config/parser.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { type } from 'arktype'; -import log from '~server/utils/log'; - -const goBool = type('boolean | "true" | "false"').pipe((v) => { - if (v === 'true') return true; - if (v === 'false') return false; - return v; -}); - -const goDuration = type('0 | string').pipe((v) => { - return v.toString(); -}); - -const databaseConfig = type({ - type: '"sqlite" | "sqlite3"', - sqlite: { - path: 'string', - write_head_log: goBool.default(true), - wal_autocheckpoint: 'number = 1000', - }, -}) - .or({ - type: '"postgres"', - postgres: { - host: 'string', - port: 'number | ""', - name: 'string', - user: 'string', - pass: 'string', - max_open_conns: 'number = 10', - max_idle_conns: 'number = 10', - conn_max_idle_time_secs: 'number = 3600', - ssl: goBool.default(false), - }, - }) - .merge({ - debug: goBool.default(false), - 'gorm?': { - prepare_stmt: goBool.default(true), - parameterized_queries: goBool.default(true), - skip_err_record_not_found: goBool.default(true), - slow_threshold: 'number = 1000', - }, - }); - -// Not as strict parsing because we just need the values -// to be slightly truthy enough to safely modify them -export type HeadscaleConfig = typeof headscaleConfig.infer; -const headscaleConfig = type({ - server_url: 'string', - listen_addr: 'string', - 'metrics_listen_addr?': 'string', - grpc_listen_addr: 'string = ":50433"', - grpc_allow_insecure: goBool.default(false), - noise: { - private_key_path: 'string', - }, - prefixes: { - v4: 'string', - v6: 'string', - allocation: '"sequential" | "random" = "sequential"', - }, - derp: { - server: { - enabled: goBool.default(true), - region_id: 'number?', - region_code: 'string?', - region_name: 'string?', - stun_listen_addr: 'string?', - private_key_path: 'string?', - ipv4: 'string?', - ipv6: 'string?', - automatically_add_embedded_derp_region: goBool.default(true), - }, - urls: 'string[]?', - paths: 'string[]?', - auto_update_enabled: goBool.default(true), - update_frequency: goDuration.default('24h'), - }, - - disable_check_updates: goBool.default(false), - ephemeral_node_inactivity_timeout: goDuration.default('30m'), - database: databaseConfig, - - acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"', - acme_email: 'string = ""', - tls_letsencrypt_hostname: 'string = ""', - tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"', - tls_letsencrypt_challenge_type: 'string = "HTTP-01"', - tls_letsencrypt_listen: 'string = ":http"', - 'tls_cert_path?': 'string', - 'tls_key_path?': 'string', - - log: type({ - format: 'string = "text"', - level: 'string = "info"', - }).default(() => ({ format: 'text', level: 'info' })), - - 'policy?': { - mode: '"database" | "file" = "file"', - path: 'string?', - }, - - dns: { - magic_dns: goBool.default(true), - base_domain: 'string = "headscale.net"', - nameservers: type({ - global: type('string[]').default(() => []), - split: type('Record').default(() => ({})), - }).default(() => ({ global: [], split: {} })), - search_domains: type('string[]').default(() => []), - extra_records: type({ - name: 'string', - value: 'string', - type: 'string | "A"', - }) - .array() - .default(() => []), - }, - - unix_socket: 'string?', - unix_socket_permission: 'string = "0770"', - - 'oidc?': { - only_start_if_oidc_is_available: goBool.default(false), - issuer: 'string', - client_id: 'string', - client_secret: 'string?', - client_secret_path: 'string?', - expiry: goDuration.default('180d'), - use_expiry_from_token: goBool.default(false), - scope: type('string[]').default(() => ['openid', 'email', 'profile']), - extra_params: 'Record?', - allowed_domains: 'string[]?', - allowed_groups: 'string[]?', - allowed_users: 'string[]?', - 'pkce?': { - enabled: goBool.default(false), - method: 'string = "S256"', - }, - map_legacy_users: goBool.default(false), - }, - - 'logtail?': { - enabled: goBool.default(false), - }, - - randomize_client_port: goBool.default(false), -}); - -export function validateConfig(config: unknown, strict: boolean) { - log.debug('CFGX', 'Validating Headscale configuration...'); - const out = strict - ? headscaleConfig(config) - : headscaleConfig(augmentUnstrictConfig(config as HeadscaleConfig)); - - if (out instanceof type.errors) { - log.error('CFGX', 'Error parsing Headscale configuration:'); - for (const [number, error] of out.entries()) { - log.error('CFGX', ` (${number}): ${error.toString()}`); - } - - log.error('CFGX', ''); - log.error('CFGX', 'Resolve these issues and try again.'); - log.error('CFGX', 'Headplane will operate without the config'); - log.error('CFGX', ''); - return; - } - - log.debug('CFGX', 'Headscale configuration is valid.'); - return out; -} - -// If config_strict is false, we set the defaults and disable -// the schema checking for the values that are not present -function augmentUnstrictConfig( - loaded: Partial, -): HeadscaleConfig { - log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode'); - const config = { - ...loaded, - tls_letsencrypt_cache_dir: - loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache', - tls_letsencrypt_challenge_type: - loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01', - grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443', - grpc_allow_insecure: loaded.grpc_allow_insecure ?? false, - randomize_client_port: loaded.randomize_client_port ?? false, - unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock', - unix_socket_permission: loaded.unix_socket_permission ?? '0770', - - log: loaded.log ?? { - level: 'info', - format: 'text', - }, - - logtail: loaded.logtail ?? { - enabled: false, - }, - - prefixes: loaded.prefixes ?? { - allocation: 'sequential', - v4: '', - v6: '', - }, - - dns: loaded.dns ?? { - nameservers: { - global: [], - split: {}, - }, - search_domains: [], - extra_records: [], - magic_dns: false, - base_domain: 'headscale.net', - }, - }; - - log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode'); - log.warn('CFGX', 'By using this mode you forfeit GitHub issue support'); - log.warn('CFGX', 'This is very dangerous and comes with a few caveats:'); - log.warn('CFGX', ' Headplane could very easily crash'); - log.warn('CFGX', ' Headplane could break your Headscale installation'); - log.warn('CFGX', ' The UI could throw random errors/show incorrect data'); - log.warn('CFGX', ''); - - return config as HeadscaleConfig; -}