From dc32427cff5eb68036326c0ece6078ea771483f3 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 20 Jun 2026 18:28:36 -0400 Subject: [PATCH] feat(config): use minimal headscale config --- app/routes/dns/dns-actions.ts | 25 +- app/routes/dns/overview.tsx | 12 +- app/routes/home.tsx | 2 +- app/routes/machines/machine.tsx | 7 +- app/routes/machines/overview.tsx | 7 +- app/routes/settings/restrictions/actions.ts | 16 +- app/routes/settings/restrictions/overview.tsx | 9 +- app/routes/users/overview.tsx | 7 +- app/server/headscale/config-loader.ts | 224 +++++++++++------- app/server/headscale/config-schema.ts | 166 ------------- tests/unit/headscale/config-loader.test.ts | 96 ++++++++ 11 files changed, 259 insertions(+), 312 deletions(-) delete mode 100644 app/server/headscale/config-schema.ts create mode 100644 tests/unit/headscale/config-loader.test.ts diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index eb06df8..056e915 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -67,7 +67,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return { message: "Magic DNS state updated successfully" }; } case "remove_ns": { - const config = headscaleConfig.c!; + const config = headscaleConfig.getDNSConfig(); const ns = formData.get("ns")?.toString(); const splitName = formData.get("split_name")?.toString(); @@ -76,7 +76,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { } if (splitName === "global") { - const servers = config.dns.nameservers.global.filter((i) => i !== ns); + const servers = config.nameservers.filter((i) => i !== ns); await headscaleConfig.patch([ { @@ -85,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); } else { - const splits = config.dns.nameservers.split; + const splits = config.splitDns; const servers = splits[splitName].filter((i) => i !== ns); await headscaleConfig.patch([ @@ -100,7 +100,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return { message: "Nameserver removed successfully" }; } case "add_ns": { - const config = headscaleConfig.c!; + const config = headscaleConfig.getDNSConfig(); const ns = formData.get("ns")?.toString(); const splitName = formData.get("split_name")?.toString(); @@ -109,8 +109,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { } if (splitName === "global") { - const servers = config.dns.nameservers.global; - servers.push(ns); + const servers = [...config.nameservers, ns]; await headscaleConfig.patch([ { @@ -119,9 +118,8 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { }, ]); } else { - const splits = config.dns.nameservers.split; - const servers = splits[splitName] ?? []; - servers.push(ns); + const splits = config.splitDns; + const servers = [...(splits[splitName] ?? []), ns]; await headscaleConfig.patch([ { @@ -135,13 +133,13 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return { message: "Nameserver added successfully" }; } case "remove_domain": { - const config = headscaleConfig.c!; + const config = headscaleConfig.getDNSConfig(); const domain = formData.get("domain")?.toString(); if (!domain) { return data({ success: false }, 400); } - const domains = config.dns.search_domains.filter((i) => i !== domain); + const domains = config.searchDomains.filter((i) => i !== domain); await headscaleConfig.patch([ { path: "dns.search_domains", @@ -153,14 +151,13 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return { message: "Domain removed successfully" }; } case "add_domain": { - const config = headscaleConfig.c!; + const config = headscaleConfig.getDNSConfig(); const domain = formData.get("domain")?.toString(); if (!domain) { return data({ success: false }, 400); } - const domains = config.dns.search_domains; - domains.push(domain); + const domains = [...config.searchDomains, domain]; await headscaleConfig.patch([ { diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 183f29a..b28e588 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -35,17 +35,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const writablePermission = auth.can(principal, Capabilities.write_network); - const config = headscaleConfig.c!; - const dns = { - prefixes: config.prefixes, - magicDns: config.dns.magic_dns, - baseDomain: config.dns.base_domain, - nameservers: config.dns.nameservers.global, - splitDns: config.dns.nameservers.split, - searchDomains: config.dns.search_domains, - overrideDns: config.dns.override_local_dns, - extraRecords: headscaleConfig.d, - }; + const dns = headscaleConfig.getDNSConfig(); return { ...dns, diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 5e1c74c..aad6e0b 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -61,7 +61,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { // Only warn if Headscale isn't using OIDC — if it is, the user // Just needs to connect a device and Headscale will auto-create // Their account, at which point auto-link will pick it up. - if (!headscaleConfig.c?.oidc) { + if (!headscaleConfig.hasOIDCConfig()) { unlinked = true; } } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 14ba7bb..cca5412 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -43,12 +43,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { throw data(null, { status: 204 }); } - let magic: string | undefined; - if (headscaleConfig.readable()) { - if (headscaleConfig.c?.dns.magic_dns) { - magic = headscaleConfig.c.dns.base_domain; - } - } + const magic = headscaleConfig.getMagicDNSBaseDomain(); const { api } = await getRequestApi(request); const [nodesSnap, usersSnap] = await Promise.all([ diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index d543c1c..18d529b 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -56,12 +56,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const nodes = nodesSnap.data; const users = usersSnap.data; - let magic: string | undefined; - if (headscaleConfig.readable()) { - if (headscaleConfig.c?.dns.magic_dns) { - magic = headscaleConfig.c.dns.base_domain; - } - } + const magic = headscaleConfig.getMagicDNSBaseDomain(); const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined; const [statsResult, policyResult] = await Promise.allSettled([ diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 763d882..5d2d1b6 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -48,7 +48,9 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const domains = [...new Set([...(headscaleConfig.c?.oidc?.allowed_domains ?? []), domain])]; + const domains = [ + ...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]), + ]; await headscaleConfig.patch([ { @@ -69,7 +71,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const storedDomains = headscaleConfig.c?.oidc?.allowed_domains ?? []; + const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? []; if (!storedDomains.includes(domain)) { // Domain not found in the list throw data(`Domain "${domain}" not found in allowed domains.`, { @@ -97,7 +99,9 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const groups = [...new Set([...(headscaleConfig.c?.oidc?.allowed_groups ?? []), group])]; + const groups = [ + ...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]), + ]; await headscaleConfig.patch([ { @@ -118,7 +122,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const storedGroups = headscaleConfig.c?.oidc?.allowed_groups ?? []; + const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? []; if (!storedGroups.includes(group)) { // Group not found in the list throw data(`Group "${group}" not found in allowed groups.`, { @@ -147,7 +151,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const users = [...new Set([...(headscaleConfig.c?.oidc?.allowed_users ?? []), user])]; + const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])]; await headscaleConfig.patch([ { @@ -168,7 +172,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) }); } - const storedUsers = headscaleConfig.c?.oidc?.allowed_users ?? []; + const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? []; if (!storedUsers.includes(user)) { // User not found in the list throw data(`User "${user}" not found in allowed users.`, { diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx index a1d9ddf..bcd76ee 100644 --- a/app/routes/settings/restrictions/overview.tsx +++ b/app/routes/settings/restrictions/overview.tsx @@ -24,7 +24,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { }); } - if (!headscaleConfig.c?.oidc) { + const oidc = headscaleConfig.getOIDCConfig(); + if (!oidc) { throw data("OIDC is not configured on this Headscale instance.", { status: 501, }); @@ -33,9 +34,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { return { access: auth.can(principal, Capabilities.configure_iam), settings: { - domains: [...new Set(headscaleConfig.c.oidc.allowed_domains)], - groups: [...new Set(headscaleConfig.c.oidc.allowed_groups)], - users: [...new Set(headscaleConfig.c.oidc.allowed_users)], + domains: [...new Set(oidc.allowedDomains)], + groups: [...new Set(oidc.allowedGroups)], + users: [...new Set(oidc.allowedUsers)], }, writable: headscaleConfig.writable(), }; diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 1817f7c..04b3df5 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -138,12 +138,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { claimed: claimedIds.has(u.id), })); - let magic: string | undefined; - if (headscaleConfig.readable()) { - if (headscaleConfig.c?.dns.magic_dns) { - magic = headscaleConfig.c.dns.base_domain; - } - } + const magic = headscaleConfig.getMagicDNSBaseDomain(); const isOwner = isUserPrincipal(principal) && principal.user.role === "owner"; diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index e06058c..97ceccd 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -2,24 +2,43 @@ import { constants, access, readFile, writeFile } from "node:fs/promises"; import { exit } from "node:process"; import { setTimeout } from "node:timers/promises"; -import { type } from "arktype"; import { Document, parseDocument } from "yaml"; import log from "~/utils/log"; import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns"; -import { headscaleConfig } from "./config-schema"; interface PatchConfig { path: string; value: unknown; } +interface DNSConfigView { + prefixes: { + v4?: string; + v6?: string; + allocation: "sequential" | "random"; + }; + magicDns: boolean; + baseDomain: string; + nameservers: string[]; + splitDns: Record; + searchDomains: string[]; + overrideDns: boolean; + extraRecords: DNSRecord[]; +} + +interface OIDCConfigView { + issuer: string; + allowedDomains: string[]; + allowedGroups: string[]; + allowedUsers: string[]; +} + // We need a class for the config because we need to be able to // support retrieving it via a getter but also be able to // 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; @@ -29,12 +48,10 @@ class HeadscaleConfig { constructor( access: "rw" | "ro" | "no", dns?: HeadscaleDNSConfig, - config?: typeof headscaleConfig.infer, document?: Document, path?: string, ) { this.access = access; - this.config = config; this.document = document; this.path = path; this.dns = dns; @@ -48,16 +65,58 @@ class HeadscaleConfig { return this.access === "rw"; } - get c() { - return this.config; + getDNSConfig(): DNSConfigView { + const config = this.rawConfig(); + const prefixes = readObject(config, ["prefixes"]); + const dns = readObject(config, ["dns"]); + const nameservers = readObject(config, ["dns", "nameservers"]); + + return { + prefixes: { + v4: readString(prefixes?.v4), + v6: readString(prefixes?.v6), + allocation: prefixes?.allocation === "random" ? "random" : "sequential", + }, + magicDns: readBoolean(dns?.magic_dns, true), + baseDomain: readString(dns?.base_domain) ?? "", + nameservers: readStringArray(nameservers?.global), + splitDns: readStringArrayRecord(nameservers?.split), + searchDomains: readStringArray(dns?.search_domains), + overrideDns: readBoolean(dns?.override_local_dns, true), + extraRecords: this.dnsRecords(), + }; } - get d() { + getMagicDNSBaseDomain() { + if (!this.readable()) return; + const dns = this.getDNSConfig(); + return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined; + } + + getOIDCConfig(): OIDCConfigView | undefined { + const oidc = readObject(this.rawConfig(), ["oidc"]); + if (!oidc) return; + const issuer = readString(oidc.issuer); + if (!issuer) return; + + return { + issuer, + allowedDomains: readStringArray(oidc.allowed_domains), + allowedGroups: readStringArray(oidc.allowed_groups), + allowedUsers: readStringArray(oidc.allowed_users), + }; + } + + hasOIDCConfig() { + return this.getOIDCConfig() !== undefined; + } + + dnsRecords() { if (this.dns) { return this.dns.r; } - return this.config?.dns.extra_records ?? []; + return readDNSRecords(readPath(this.rawConfig(), ["dns", "extra_records"])); } async patch(patches: PatchConfig[]) { @@ -105,14 +164,6 @@ class HeadscaleConfig { 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); // We need to lock the writeLock so that we don't try to write @@ -123,7 +174,6 @@ class HeadscaleConfig { this.writeLock = true; await writeFile(this.path, this.document.toString(), "utf8"); - this.config = config; this.writeLock = false; return; } @@ -152,7 +202,7 @@ class HeadscaleConfig { // If we get here, we need to add to the main config instead of // a separate file (which requires an integration restart) - const existing = this.config?.dns.extra_records ?? []; + const existing = this.dnsRecords(); if (existing.some((i) => i.name === record.name && i.type === record.type)) { log.debug("config", "DNS record already exists"); return; @@ -192,7 +242,7 @@ class HeadscaleConfig { // If we get here, we need to remove from the main config instead of // a separate file (which requires an integration restart) - const existing = this.config?.dns.extra_records ?? []; + const existing = this.dnsRecords(); const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type); // If the length of the existing records is the same as the filtered @@ -210,6 +260,10 @@ class HeadscaleConfig { return true; } + + private rawConfig() { + return this.document?.toJSON() ?? {}; + } } export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) { @@ -230,29 +284,23 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath? } if (!strict) { - return new HeadscaleConfig( - w ? "rw" : "ro", - new HeadscaleDNSConfig("no"), - augmentUnstrictConfig(document.toJSON()), - document, - path, - ); + log.warn("config", "headscale.config_strict=false is no longer required for unknown keys"); + log.warn("config", "Headplane now validates only the Headscale config values it reads/writes"); } - const config = validateConfig(document.toJSON()); - if (!config) { - return new HeadscaleConfig("no"); - } + const rawConfig = document.toJSON(); + const inlineExtraRecords = readPath(rawConfig, ["dns", "extra_records"]); + const extraRecordsPath = readString(readPath(rawConfig, ["dns", "extra_records_path"])); - if (config.dns.extra_records && config.dns.extra_records_path) { + if (Array.isArray(inlineExtraRecords) && extraRecordsPath) { log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash"); log.warn("config", "Please remove one of them from the configuration file"); return new HeadscaleConfig("no"); } - const dns = await loadHeadscaleDNS(dnsPath ?? config.dns.extra_records_path); - if (dns && !config.dns.extra_records_path) { + const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath); + if (dns && !extraRecordsPath) { log.error( "config", "Using separate DNS config file but dns.extra_records_path is not set in Headscale config", @@ -263,7 +311,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath? exit(1); } - return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path); + return new HeadscaleConfig(w ? "rw" : "ro", dns, document, path); } async function validateConfigPath(path: string) { @@ -307,66 +355,58 @@ async function loadConfigFile(path: string) { } } -export function validateConfig(config: unknown) { - log.debug("config", "Validating Headscale configuration"); - const result = headscaleConfig(config); - if (result instanceof type.errors) { - log.error("config", "Error validating Headscale configuration:"); - for (const [number, error] of result.entries()) { - log.error("config", ` - (${number}): ${error.toString()}`); - } - - return; +function readPath(config: unknown, path: string[]) { + let current = config; + for (const segment of path) { + if (!isObject(current)) return; + current = current[segment]; } + return current; +} +function readObject(config: unknown, path: string[]) { + const value = readPath(config, path); + return isObject(value) ? value : undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown) { + return typeof value === "string" ? value : undefined; +} + +function readBoolean(value: unknown, fallback: boolean) { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return fallback; +} + +function readStringArray(value: unknown) { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function readStringArrayRecord(value: unknown) { + if (!isObject(value)) return {}; + + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = readStringArray(item); + } return result; } -// 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) { - log.debug("config", "Augmenting 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, - 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("config", "Headscale configuration was loaded in non-strict mode"); - log.warn("config", "This is very dangerous and comes with a few caveats:"); - log.warn("config", " - Headplane could very easily crash"); - log.warn("config", " - Headplane could break your Headscale installation"); - log.warn("config", " - The UI could throw random errors/show incorrect data"); - - return config as typeof headscaleConfig.infer; +function readDNSRecords(value: unknown) { + if (!Array.isArray(value)) return []; + return value.filter((item): item is DNSRecord => { + if (!isObject(item)) return false; + return ( + typeof item.name === "string" && + typeof item.type === "string" && + typeof item.value === "string" + ); + }); } diff --git a/app/server/headscale/config-schema.ts b/app/server/headscale/config-schema.ts deleted file mode 100644 index 2381f87..0000000 --- a/app/server/headscale/config-schema.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { type } from "arktype"; - -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_ahead_log: goBool.default(true), - wal_autocheckpoint: "number = 1000", - }, -}) - .or({ - type: '"postgres"', - postgres: { - host: "string", - port: 'number | ""', - name: "string", - user: "string", - pass: "string?", - password_file: "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; -export 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, - "node?": { - expiry: goDuration.default("0"), - "ephemeral?": { - inactivity_timeout: goDuration.default("30m"), - }, - "routes?": { - "ha?": { - probe_interval: goDuration.default("10s"), - probe_timeout: goDuration.default("5s"), - }, - }, - }, - 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"', - override_local_dns: goBool.default(false), - 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() - .optional(), - extra_records_path: "string?", - }, - - 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, - "auto_update?": { - enabled: goBool.default(false), - }, -}); diff --git a/tests/unit/headscale/config-loader.test.ts b/tests/unit/headscale/config-loader.test.ts new file mode 100644 index 0000000..4235b76 --- /dev/null +++ b/tests/unit/headscale/config-loader.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { loadHeadscaleConfig } from "~/server/headscale/config-loader"; + +describe("Headscale config loader", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "headplane-config-loader-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("tolerates unknown and version-specific keys while reading known defaults", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + [ + "server_url: http://localhost:8080", + "future_headscale_key:", + " nested: true", + "randomize_client_port: false", + "auto_update:", + " enabled: true", + ].join("\n"), + ); + + const config = await loadHeadscaleConfig(path); + expect(config.readable()).toBe(true); + expect(config.getDNSConfig()).toMatchObject({ + magicDns: true, + baseDomain: "", + nameservers: [], + splitDns: {}, + searchDomains: [], + overrideDns: true, + }); + }); + + test("patches only requested paths and does not write effective defaults", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + [ + "server_url: http://localhost:8080", + "future_headscale_key: keep-me", + "dns:", + " base_domain: example.com", + ].join("\n"), + ); + + const config = await loadHeadscaleConfig(path); + await config.patch([{ path: "dns.magic_dns", value: false }]); + + const written = parse(await readFile(path, "utf8")); + expect(written.future_headscale_key).toBe("keep-me"); + expect(written.dns).toEqual({ + base_domain: "example.com", + magic_dns: false, + }); + }); + + test("reads OIDC restrictions as empty arrays when unset", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + ["server_url: http://localhost:8080", "oidc:", " issuer: https://issuer.example.com"].join( + "\n", + ), + ); + + const config = await loadHeadscaleConfig(path); + expect(config.getOIDCConfig()).toEqual({ + issuer: "https://issuer.example.com", + allowedDomains: [], + allowedGroups: [], + allowedUsers: [], + }); + }); + + test("does not treat an empty OIDC block as configured", async () => { + const path = join(dir, "config.yaml"); + await writeFile(path, ["server_url: http://localhost:8080", "oidc: {}"].join("\n")); + + const config = await loadHeadscaleConfig(path); + expect(config.getOIDCConfig()).toBeUndefined(); + expect(config.hasOIDCConfig()).toBe(false); + }); +});