// Handle the configuration loading for headscale. // Functionally only used for reading and writing the configuration file. // Availability checks and other configuration checks are done in the headplane // configuration file that's adjacent to this one. // // Around the codebase, this is referred to as the config // Refer to this file on juanfont/headscale for the default values: // https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { type Document, parseDocument } from 'yaml' import { z } from 'zod' const goBool = z .union([z.boolean(), z.literal('true'), z.literal('false')]) .transform((value) => { if (typeof value === 'boolean') { return value } return value === 'true' }) const goDuration = z.union([z.literal(0), z.string()]) const HeadscaleConfig = z.object({ tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'), tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'), tls_letsencrypt_hostname: z.string().optional(), tls_letsencrypt_listen: z.string().optional(), tls_cert_path: z.string().optional(), tls_key_path: z.string().optional(), server_url: z.string().regex(/^https?:\/\//), listen_addr: z.string(), metrics_listen_addr: z.string().optional(), grpc_listen_addr: z.string().default(':50443'), grpc_allow_insecure: goBool.default(false), disable_check_updates: goBool.default(false), ephemeral_node_inactivity_timeout: goDuration.default('120s'), randomize_client_port: goBool.default(false), acl_policy_path: z.string().optional(), acme_email: z.string().optional(), acme_url: z.string().optional(), unix_socket: z.string().default('/var/run/headscale/headscale.sock'), unix_socket_permission: z.string().default('0o770'), tuning: z.object({ batch_change_delay: goDuration.default('800ms'), node_mapsession_buffered_chan_size: z.number().default(30), }).optional(), noise: z.object({ private_key_path: z.string(), }), log: z.object({ level: z.string().default('info'), format: z.enum(['text', 'json']).default('text'), }).default({ level: 'info', format: 'text' }), logtail: z.object({ enabled: goBool.default(false), }).default({ enabled: false }), cli: z.object({ address: z.string().optional(), api_key: z.string().optional(), timeout: goDuration.default('10s'), insecure: goBool.default(false), }).optional(), prefixes: z.object({ allocation: z.enum(['sequential', 'random']).default('sequential'), v4: z.string(), v6: z.string(), }), dns_config: z.object({ override_local_dns: goBool.default(false), nameservers: z.array(z.string()).default([]), restricted_nameservers: z.record(z.array(z.string())).default({}), domains: z.array(z.string()).default([]), extra_records: z.array(z.object({ name: z.string(), type: z.literal('A'), value: z.string(), })).default([]), magic_dns: goBool.default(false), base_domain: z.string().default('headscale.net'), }), oidc: z.object({ only_start_if_oidc_is_available: goBool.default(false), issuer: z.string().optional(), client_id: z.string().optional(), client_secret: z.string().optional(), client_secret_path: z.string().optional(), scope: z.array(z.string()).default(['openid', 'profile', 'email']), extra_params: z.record(z.unknown()).default({}), allowed_domains: z.array(z.string()).optional(), allowed_users: z.array(z.string()).optional(), allowed_groups: z.array(z.string()).optional(), strip_email_domain: goBool.default(false), expiry: goDuration.default('180d'), use_expiry_from_token: goBool.default(false), }).optional(), database: z.union([ z.object({ type: z.literal('sqlite'), debug: goBool.default(false), sqlite: z.object({ path: z.string(), }), }), z.object({ type: z.literal('sqlite3'), debug: goBool.default(false), sqlite: z.object({ path: z.string(), }), }), z.object({ type: z.literal('postgres'), debug: goBool.default(false), postgres: z.object({ host: z.string(), port: z.number(), name: z.string(), user: z.string(), pass: z.string(), ssl: goBool.default(true), max_open_conns: z.number().default(10), max_idle_conns: z.number().default(10), conn_max_idle_time_secs: z.number().default(3600), }), }), ]), derp: z.object({ server: z.object({ enabled: goBool.default(true), region_id: z.number().optional(), region_code: z.string().optional(), region_name: z.string().optional(), stun_listen_addr: z.string().optional(), private_key_path: z.string().optional(), ipv4: z.string().optional(), ipv6: z.string().optional(), automatically_add_embedded_derp_region: goBool.default(true), }), urls: z.array(z.string()).optional(), paths: z.array(z.string()).optional(), auto_update_enabled: goBool.default(true), update_frequency: goDuration.default('24h'), }), }) export type HeadscaleConfig = z.infer export let configYaml: Document | undefined export let config: HeadscaleConfig | undefined export async function loadConfig(path?: string) { if (config) { return config } if (!path) { throw new Error('Path is required to lazy load config') } const data = await readFile(path, 'utf8') configYaml = parseDocument(data) if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') { const loaded = configYaml.toJSON() as Record 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 ?? '0o770', tuning: loaded.tuning ?? { batch_change_delay: '800ms', node_mapsession_buffered_chan_size: 30, }, log: loaded.log ?? { level: 'info', format: 'text', }, logtail: loaded.logtail ?? { enabled: false, }, cli: loaded.cli ?? { timeout: '10s', insecure: false, }, prefixes: loaded.prefixes ?? { allocation: 'sequential', v4: '', v6: '', }, dns_config: loaded.dns_config ?? { override_local_dns: false, nameservers: [], restricted_nameservers: {}, domains: [], extra_records: [], magic_dns: false, base_domain: 'headscale.net', }, } as HeadscaleConfig console.log('Loaded Headscale configuration in non-strict mode') console.log('By using this mode you forfeit GitHub issue support') console.log('This is very dangerous and comes with a few caveats:') console.log('- Headplane could very easily crash') console.log('- Headplane could break your Headscale installation') console.log('- The UI could throw random errors/show incorrect data') console.log('') return config } try { config = await HeadscaleConfig.parseAsync(configYaml.toJSON()) } catch (error) { if (error instanceof z.ZodError) { console.log('Failed to parse the Headscale configuration file!') console.log('The following schema issues were found:') for (const issue of error.issues) { const path = issue.path.map(String).join('.') const message = issue.message console.log(`- '${path}': ${message}`) } console.log('') console.log('Please fix the configuration file and try again.') console.log('Headplane will operate as if no config is present.') console.log('') } throw error } return config } // This is so obscenely dangerous, please have a check around it export async function patchConfig(partial: Record) { if (!configYaml || !config) { throw new Error('Config not loaded') } for (const [key, value] of Object.entries(partial)) { // 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 path = [] let temp = '' let inQuote = false for (const element of key) { if (element === '"') { inQuote = !inQuote } if (element === '.' && !inQuote) { path.push(temp.replaceAll('"', '')) temp = '' continue } temp += element } // Push the remaining element path.push(temp.replaceAll('"', '')) configYaml.setIn(path, value) } config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true' ? configYaml.toJSON() as HeadscaleConfig : (await HeadscaleConfig.parseAsync(configYaml.toJSON())) const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') await writeFile(path, configYaml.toString(), 'utf8') }