import { type Document, parse, parseDocument } from 'yaml' import { type FSWatcher, watch } from 'node:fs' import { access, constants, readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y` interface Config { server_url: string listen_addr: string metrics_listen_addr: string grpc_listen_addr: string grpc_allow_insecure: boolean private_key_path: string noise: { private_key_path: string } prefixes: { v4: string v6: string } derp: { server: { enabled: boolean region_id: number region_code: string region_name: string stun_listen_addr: string } urls: string[] paths: string[] auto_update_enabled: boolean update_frequency: Duration } disable_check_updates: boolean epheremal_node_inactivity_timeout: Duration node_update_check_interval: Duration // Database is probably dangerous database: { type: 'sqlite3' | 'sqlite' | 'postgres' sqlite?: { path: string } postgres?: { host: string port: number name: string user: string pass: string max_open_conns: number max_idle_conns: number conn_max_idle_time_secs: number ssl: boolean } } acme_url: string acme_email: string tls_letsencrypt_hostname: string tls_letsencrypt_cache_dir: string tls_letsencrypt_challenge_type: string tls_letsencrypt_listen: string tls_cert_path: string tls_key_path: string log: { format: 'text' | 'json' level: string } acl_policy_path: string dns_config: { override_local_dns: boolean nameservers: string[] restricted_nameservers: Record // Split DNS domains: string[] extra_records: { name: string type: 'A' value: string }[] magic_dns: boolean base_domain: string } unix_socket: string unix_socket_permission: string oidc: { only_start_if_oidc_is_available: boolean issuer: string client_id: string client_secret: string expiry: Duration use_expiry_from_token: boolean scope: string[] extra_params: Record allowed_domains: string[] allowed_groups: string[] allowed_users: string[] strip_email_domain: boolean } logtail: { enabled: boolean } randomize_client_port: boolean } let config: Document export async function getConfig(force = false) { if (!config || force) { const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') const data = await readFile(path, 'utf8') config = parseDocument(data) } return config.toJSON() as Config } export async function getAcl() { let path = process.env.ACL_FILE if (!path) { try { const config = await getConfig() path = config.acl_policy_path } catch {} } if (!path) { return { data: '', type: 'json' } } const data = await readFile(path, 'utf8') // Naive check for YAML over JSON // This is because JSON.parse doesn't support comments try { parse(data) return { data, type: 'yaml' } } catch { return { data, type: 'json' } } } // This is so obscenely dangerous, please have a check around it export async function patchConfig(partial: Record) { for (const [key, value] of Object.entries(partial)) { config.setIn(key.split('.'), value) } const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') await writeFile(path, config.toString(), 'utf8') } export async function patchAcl(data: string) { let path = process.env.ACL_FILE if (!path) { try { const config = await getConfig() path = config.acl_policy_path } catch {} } if (!path) { throw new Error('No ACL file defined') } await writeFile(path, data, 'utf8') } let watcher: FSWatcher export function registerConfigWatcher() { if (watcher) { return } const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') watcher = watch(path, async () => { console.log('Config file changed, reloading') await getConfig(true) }) } export interface Context { hasDockerSock: boolean hasConfig: boolean hasConfigWrite: boolean hasAcl: boolean hasAclWrite: boolean headscaleUrl: string oidcConfig?: { issuer: string client: string secret: string } } export let context: Context export async function getContext() { if (!context) { context = { hasDockerSock: await checkSock(), hasConfig: await hasConfig(), hasConfigWrite: await hasConfigW(), hasAcl: await hasAcl(), hasAclWrite: await hasAclW(), headscaleUrl: await getHeadscaleUrl(), oidcConfig: await getOidcConfig(), } } return context } async function getOidcConfig() { // Check for the OIDC environment variables first let issuer = process.env.OIDC_ISSUER let client = process.env.OIDC_CLIENT_ID let secret = process.env.OIDC_CLIENT_SECRET const rootKey = process.env.API_KEY if (!issuer || !client || !secret) { const config = await getConfig() issuer = config.oidc.issuer client = config.oidc.client_id secret = config.oidc.client_secret } // If atleast one is defined but not all 3, throw an error if ((issuer || client || secret) && !(issuer && client && secret)) { throw new Error('OIDC configuration is incomplete') } if (!issuer || !client || !secret) { return } if (!rootKey) { throw new Error('Cannot use OIDC without the root API_KEY variable set') } return { issuer, client, secret } } async function getHeadscaleUrl() { if (process.env.HEADSCALE_URL) { return process.env.HEADSCALE_URL } try { const config = await getConfig() if (config.server_url) { return config.server_url } } catch {} return '' } async function checkSock() { try { await access('/var/run/docker.sock', constants.R_OK) return true } catch {} if (!process.env.HEADSCALE_CONTAINER) { return false } return false } async function hasConfig() { try { await getConfig() return true } catch {} return false } async function hasConfigW() { const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') try { await access(path, constants.W_OK) return true } catch {} return false } async function hasAcl() { let path = process.env.ACL_FILE if (!path) { try { const config = await getConfig() path = config.acl_policy_path } catch {} } if (!path) { return false } try { path = resolve(path) await access(path, constants.R_OK) return true } catch (error) { console.log('Cannot acquire read access to ACL file', error) } return false } async function hasAclW() { let path = process.env.ACL_FILE if (!path) { try { const config = await getConfig() path = config.acl_policy_path } catch {} } if (!path) { return false } try { path = resolve(path) await access(path, constants.W_OK) return true } catch (error) { console.log('Cannot acquire read access to ACL file', error) } return false }