diff --git a/app/server/context.ts b/app/server/context.ts index 2263ded..e72ecf4 100644 --- a/app/server/context.ts +++ b/app/server/context.ts @@ -75,7 +75,6 @@ export async function createAppContext(config: HeadplaneConfig) { const hsLive = createLiveStore([nodesResource, usersResource]); const hs = await loadHeadscaleConfig( config.headscale.config_path, - config.headscale.config_strict, config.headscale.dns_records_path, ); const integration = await loadIntegration(config.integration); diff --git a/app/server/headscale/config-dns.ts b/app/server/headscale/config-dns.ts index 66f087c..e40d4b0 100644 --- a/app/server/headscale/config-dns.ts +++ b/app/server/headscale/config-dns.ts @@ -99,7 +99,7 @@ async function validateConfigPath(path: string) { try { await access(path, constants.F_OK | constants.W_OK); return { w: true, r: true }; - } catch (error) { + } catch { log.warn("config", "Headscale DNS file at %s is not writable", path); return { w: false, r: true }; } diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index 97ceccd..60baffe 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -1,7 +1,7 @@ import { constants, access, readFile, writeFile } from "node:fs/promises"; import { exit } from "node:process"; -import { setTimeout } from "node:timers/promises"; +import * as v from "valibot"; import { Document, parseDocument } from "yaml"; import log from "~/utils/log"; @@ -14,11 +14,6 @@ interface PatchConfig { } interface DNSConfigView { - prefixes: { - v4?: string; - v6?: string; - allocation: "sequential" | "random"; - }; magicDns: boolean; baseDomain: string; nameservers: string[]; @@ -35,268 +30,348 @@ interface OIDCConfigView { 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 document?: Document; - private access: "rw" | "ro" | "no"; - private path?: string; - private writeLock = false; - private dns?: HeadscaleDNSConfig; +interface ParsedDNSConfig { + magic_dns: boolean; + base_domain: string; + nameservers: { + global: string[]; + split: Record; + }; + search_domains: string[]; + override_local_dns: boolean; + extra_records: DNSRecord[]; + extra_records_path?: string; +} - constructor( - access: "rw" | "ro" | "no", - dns?: HeadscaleDNSConfig, - document?: Document, - path?: string, - ) { - this.access = access; - this.document = document; - this.path = path; - this.dns = dns; +const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = { + magic_dns: true, + base_domain: "", + nameservers: { + global: [], + split: {}, + }, + search_domains: [], + override_local_dns: true, + extra_records: [], +}; + +const stringSchema = v.string(); +const stringArraySchema = v.array(v.string()); +const stringArrayRecordSchema = v.record(v.string(), stringArraySchema); +const goBooleanSchema = v.pipe( + v.union([v.boolean(), v.picklist(["true", "false"])]), + v.transform((value) => value === true || value === "true"), +); +const dnsRecordsSchema = v.array( + v.object({ + name: v.string(), + type: v.string(), + value: v.string(), + }), +); +const nameserversSchema = v.object({ + global: v.optional(v.fallback(stringArraySchema, []), []), + split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}), +}); +const dnsConfigSchema = v.object({ + magic_dns: v.optional( + v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns), + DNS_CONFIG_DEFAULTS.magic_dns, + ), + base_domain: v.optional( + v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain), + DNS_CONFIG_DEFAULTS.base_domain, + ), + nameservers: v.optional( + v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers), + DNS_CONFIG_DEFAULTS.nameservers, + ), + search_domains: v.optional( + v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains), + DNS_CONFIG_DEFAULTS.search_domains, + ), + override_local_dns: v.optional( + v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns), + DNS_CONFIG_DEFAULTS.override_local_dns, + ), + extra_records: v.optional( + v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records), + DNS_CONFIG_DEFAULTS.extra_records, + ), + extra_records_path: v.optional(v.string()), +}); +const headscaleConfigSchema = v.fallback( + v.object({ + dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS), + }), + { dns: DNS_CONFIG_DEFAULTS }, +); +const oidcConfigSchema = v.object({ + issuer: v.string(), + allowed_domains: v.optional(v.fallback(stringArraySchema, []), []), + allowed_groups: v.optional(v.fallback(stringArraySchema, []), []), + allowed_users: v.optional(v.fallback(stringArraySchema, []), []), +}); +const rawOIDCConfigSchema = v.fallback( + v.object({ + oidc: v.optional(v.unknown()), + }), + {}, +); +const extraRecordsConflictSchema = v.object({ + dns: v.optional( + v.object({ + extra_records: v.optional(v.array(v.unknown())), + extra_records_path: v.optional(v.string()), + }), + ), +}); + +interface HeadscaleConfigState { + document?: Document; + config: unknown; + access: "rw" | "ro" | "no"; + path?: string; + writeQueue: Promise; + dns?: HeadscaleDNSConfig; +} + +interface HeadscaleConfig { + readable: () => boolean; + writable: () => boolean; + getDNSConfig: () => DNSConfigView; + getMagicDNSBaseDomain: () => string | undefined; + getOIDCConfig: () => OIDCConfigView | undefined; + hasOIDCConfig: () => boolean; + dnsRecords: () => DNSRecord[]; + patch: (patches: PatchConfig[]) => Promise; + addDNS: (record: DNSRecord) => Promise; + removeDNS: (record: DNSRecord) => Promise; +} + +function createHeadscaleConfig( + access: "rw" | "ro" | "no", + dns?: HeadscaleDNSConfig, + document?: Document, + path?: string, +): HeadscaleConfig { + const state: HeadscaleConfigState = { + access, + config: document?.toJSON() ?? {}, + document, + path, + writeQueue: Promise.resolve(), + dns, + }; + + return { + readable: () => readable(state), + writable: () => writable(state), + getDNSConfig: () => getDNSConfig(state), + getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state), + getOIDCConfig: () => getOIDCConfig(state), + hasOIDCConfig: () => hasOIDCConfig(state), + dnsRecords: () => dnsRecords(state), + patch: (patches) => patchHeadscaleConfig(state, patches), + addDNS: (record) => addDNS(state, record), + removeDNS: (record) => removeDNS(state, record), + }; +} + +function readable(config: HeadscaleConfigState) { + return config.access !== "no"; +} + +function writable(config: HeadscaleConfigState) { + return config.access === "rw"; +} + +function getDNSConfig(config: HeadscaleConfigState): DNSConfigView { + const dns = v.parse(headscaleConfigSchema, config.config).dns; + + return { + magicDns: dns.magic_dns, + baseDomain: dns.base_domain, + nameservers: dns.nameservers.global, + splitDns: dns.nameservers.split, + searchDomains: dns.search_domains, + overrideDns: dns.override_local_dns, + extraRecords: dnsRecords(config), + }; +} + +function getMagicDNSBaseDomain(config: HeadscaleConfigState) { + if (!readable(config)) return; + const dns = getDNSConfig(config); + return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined; +} + +function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined { + const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc); + if (!oidc.success) return; + + return { + issuer: oidc.output.issuer, + allowedDomains: oidc.output.allowed_domains, + allowedGroups: oidc.output.allowed_groups, + allowedUsers: oidc.output.allowed_users, + }; +} + +function hasOIDCConfig(config: HeadscaleConfigState) { + return getOIDCConfig(config) !== undefined; +} + +function dnsRecords(config: HeadscaleConfigState) { + if (config.dns) { + return config.dns.r; } - readable() { - return this.access !== "no"; - } + return v.parse(headscaleConfigSchema, config.config).dns.extra_records; +} - writable() { - return this.access === "rw"; - } - - 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(), - }; - } - - 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 readDNSRecords(readPath(this.rawConfig(), ["dns", "extra_records"])); - } - - 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); - } - - 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 - // to the file while we're already writing to it - while (this.writeLock) { - await setTimeout(100); - } - - this.writeLock = true; - await writeFile(this.path, this.document.toString(), "utf8"); - this.writeLock = false; +async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) { + if (!config.path || !config.document || !readable(config) || !writable(config)) { return; } - /** - * Adds a DNS record to the Headscale configuration. - * Differentiates between the file mode and config mode automatically. - * @param record The DNS record to add. - * @returns True if we need to restart the integration. - */ - async addDNS(record: DNSRecord) { - if (this.dns) { - if (!this.dns.readable() || !this.dns.writable()) { - log.debug("config", "DNS config is not writable"); - return; - } + const write = config.writeQueue.then(() => writePatches(config, patches)); + config.writeQueue = write.catch(() => undefined); + await write; +} - const records = this.dns.r; - if (records.some((i) => i.name === record.name && i.type === record.type)) { - log.debug("config", "DNS record already exists"); - return; - } +async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) { + if (!config.path || !config.document) return; - return this.dns.patch([...records, record]); + log.debug("config", "Patching Headscale configuration"); + for (const patch of patches) { + const { path, value } = patch; + log.debug("config", "Patching %s with %o", path, value); + + const key = splitPatchPath(path); + if (value === null) { + config.document.deleteIn(key); + continue; } - // 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.dnsRecords(); - if (existing.some((i) => i.name === record.name && i.type === record.type)) { + config.document.setIn(key, value); + } + + log.debug("config", "Writing updated Headscale configuration to %s", config.path); + await writeFile(config.path, config.document.toString(), "utf8"); + config.config = config.document.toJSON(); +} + +function splitPatchPath(path: string) { + const key = []; + let current = ""; + let quote = false; + + for (const char of path) { + if (char === '"') { + quote = !quote; + continue; + } + + if (char === "." && !quote) { + key.push(current); + current = ""; + continue; + } + + current += char; + } + + key.push(current); + return key; +} + +async function addDNS(config: HeadscaleConfigState, record: DNSRecord) { + if (config.dns) { + if (!config.dns.readable() || !config.dns.writable()) { + log.debug("config", "DNS config is not writable"); + return; + } + + const records = config.dns.r; + if (records.some((i) => i.name === record.name && i.type === record.type)) { log.debug("config", "DNS record already exists"); return; } - await this.patch([ - { - path: "dns.extra_records", - value: Array.from(new Set([...existing, record])), - }, - ]); - - return true; + return config.dns.patch([...records, record]); } - /** - * Removes a DNS record from the Headscale configuration. - * Differentiates between the file mode and config mode automatically. - * @param records The DNS record to remove. - * @returns True if we need to restart the integration. - */ - async removeDNS(record: DNSRecord) { - // In this case we need to check both the main config and the DNS config - // to see if the record exists, and if it does, we need to remove it - // from both places. + const existing = dnsRecords(config); + if (existing.some((i) => i.name === record.name && i.type === record.type)) { + log.debug("config", "DNS record already exists"); + return; + } - if (this.dns) { - if (!this.dns.readable() || !this.dns.writable()) { - log.debug("config", "DNS config is not writable"); - return; - } + await patchHeadscaleConfig(config, [ + { + path: "dns.extra_records", + value: [...existing, record], + }, + ]); - const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type); + return true; +} - return this.dns.patch(records); - } - - // 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.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 - // records, then we don't need to do anything - if (existing.length === filtered.length) { +async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) { + if (config.dns) { + if (!config.dns.readable() || !config.dns.writable()) { + log.debug("config", "DNS config is not writable"); return; } - await this.patch([ - { - path: "dns.extra_records", - value: existing.filter((i) => i.name !== record.name || i.type !== record.type), - }, - ]); - - return true; + const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type); + return config.dns.patch(records); } - private rawConfig() { - return this.document?.toJSON() ?? {}; + const existing = dnsRecords(config); + const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type); + if (existing.length === filtered.length) { + return; } + + await patchHeadscaleConfig(config, [ + { + path: "dns.extra_records", + value: filtered, + }, + ]); + + return true; } -export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) { +export async function loadHeadscaleConfig(path?: string, dnsPath?: string) { if (!path) { log.debug("config", "No Headscale configuration file was provided"); - return new HeadscaleConfig("no"); + return createHeadscaleConfig("no"); } log.debug("config", "Loading Headscale configuration file: %s", path); const { r, w } = await validateConfigPath(path); if (!r) { - return new HeadscaleConfig("no"); + return createHeadscaleConfig("no"); } const document = await loadConfigFile(path); if (!document) { - return new HeadscaleConfig("no"); - } - - if (!strict) { - 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"); + return createHeadscaleConfig("no"); } const rawConfig = document.toJSON(); - const inlineExtraRecords = readPath(rawConfig, ["dns", "extra_records"]); - const extraRecordsPath = readString(readPath(rawConfig, ["dns", "extra_records_path"])); + const parsedConfig = v.parse(headscaleConfigSchema, rawConfig); + const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig); + const extraRecordsPath = parsedConfig.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"); + if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) { + log.warn( + "config", + "Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file", + ); } const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath); @@ -311,7 +386,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath? exit(1); } - return new HeadscaleConfig(w ? "rw" : "ro", dns, document, path); + return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path); } async function validateConfigPath(path: string) { @@ -327,7 +402,7 @@ async function validateConfigPath(path: string) { try { await access(path, constants.F_OK | constants.W_OK); return { w: true, r: true }; - } catch (error) { + } catch { log.warn("config", "Headscale configuration file at %s is not writable", path); return { w: false, r: true }; } @@ -354,59 +429,3 @@ async function loadConfigFile(path: string) { return false; } } - -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; -} - -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/config.example.yaml b/config.example.yaml index 4b42d50..da40854 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -122,15 +122,6 @@ headscale: # Generate one with: headscale apikeys create # api_key: "" - # Whether the Headscale configuration should be strictly validated - # when reading from `config_path`. If true, Headplane will not interact - # with Headscale if there are any issues with the configuration file. - # - # This is recommended to be true for production deployments to, however it - # may not work if you are using a version of Headscale that has configuration - # options unknown to Headplane. - config_strict: true - # If you are using `dns.extra_records_path` in your Headscale # configuration, Headplane will read that path automatically. Set # this only when Headplane needs to access the same file at a diff --git a/docs/Nix.md b/docs/Nix.md index 74b8691..8ef9338 100644 --- a/docs/Nix.md +++ b/docs/Nix.md @@ -85,15 +85,7 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin {config, pkgs, ...}: let format = pkgs.formats.yaml {}; - - # A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`. - settings = lib.recursiveUpdate config.services.headscale.settings { - tls_cert_path = "/dev/null"; - tls_key_path = "/dev/null"; - policy.path = "/dev/null"; - }; - - headscaleConfig = format.generate "headscale.yml" settings; + headscaleConfig = format.generate "headscale.yml" config.services.headscale.settings; in { services.headplane = { enable = true; diff --git a/docs/NixOS-options.md b/docs/NixOS-options.md index 1b3662b..788ad4f 100644 --- a/docs/NixOS-options.md +++ b/docs/NixOS-options.md @@ -73,9 +73,7 @@ _Example:_ `"/etc/headscale/config.yaml"` ## settings.headscale.config_strict -_Description:_ Headplane internally validates the Headscale configuration -to ensure that it changes the configuration in a safe way. -If you want to disable this validation, set this to false. +_Description:_ Deprecated. Headplane no longer validates the complete Headscale configuration and this option has no effect. _Type:_ boolean diff --git a/nix/options.nix b/nix/options.nix index f789d5b..8ae6c37 100644 --- a/nix/options.nix +++ b/nix/options.nix @@ -231,9 +231,8 @@ in { type = types.bool; default = true; description = '' - Headplane internally validates the Headscale configuration - to ensure that it changes the configuration in a safe way. - If you want to disable this validation, set this to false. + Deprecated. Headplane no longer validates the complete + Headscale configuration and this option has no effect. ''; }; diff --git a/package.json b/package.json index b5c2021..930c698 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "tailwind-merge": "3.6.0", "ulidx": "2.4.1", "undici": "8.5.0", + "valibot": "^1.4.1", "yaml": "2.9.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe9329..6462fec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: undici: specifier: 8.5.0 version: 8.5.0 + valibot: + specifier: ^1.4.1 + version: 1.4.1(typescript@7.0.1-rc) yaml: specifier: 2.9.0 version: 2.9.0 diff --git a/tests/unit/headscale/config-loader.test.ts b/tests/unit/headscale/config-loader.test.ts index 4235b76..37d0261 100644 --- a/tests/unit/headscale/config-loader.test.ts +++ b/tests/unit/headscale/config-loader.test.ts @@ -18,7 +18,7 @@ describe("Headscale config loader", () => { await rm(dir, { recursive: true, force: true }); }); - test("tolerates unknown and version-specific keys while reading known defaults", async () => { + test("tolerates unknown Headscale keys while reading known defaults", async () => { const path = join(dir, "config.yaml"); await writeFile( path, @@ -44,6 +44,42 @@ describe("Headscale config loader", () => { }); }); + test("falls back for invalid consumed values without rejecting the whole config", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + [ + "server_url: http://localhost:8080", + "dns:", + " magic_dns: maybe", + " base_domain: 1234", + " override_local_dns: nope", + " nameservers:", + " global: 1.1.1.1", + " split:", + " example.com: 1.1.1.1", + " search_domains: example.com", + "oidc:", + " issuer: https://issuer.example.com", + " allowed_domains: example.com", + ].join("\n"), + ); + + const config = await loadHeadscaleConfig(path); + expect(config.readable()).toBe(true); + expect(config.getDNSConfig()).toMatchObject({ + magicDns: true, + baseDomain: "", + nameservers: [], + splitDns: {}, + searchDomains: [], + overrideDns: true, + }); + expect(config.getOIDCConfig()).toMatchObject({ + allowedDomains: [], + }); + }); + test("patches only requested paths and does not write effective defaults", async () => { const path = join(dir, "config.yaml"); await writeFile( @@ -67,6 +103,53 @@ describe("Headscale config loader", () => { }); }); + test("patches quoted split DNS domains", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + ["server_url: http://localhost:8080", "dns:", " nameservers:", " split: {}"].join("\n"), + ); + + const config = await loadHeadscaleConfig(path); + await config.patch([{ path: 'dns.nameservers.split."corp.example.com"', value: ["1.1.1.1"] }]); + + const written = parse(await readFile(path, "utf8")); + expect(written.dns.nameservers.split).toEqual({ + "corp.example.com": ["1.1.1.1"], + }); + }); + + test("prefers extra records JSON over inline YAML records", async () => { + const path = join(dir, "config.yaml"); + const recordsPath = join(dir, "extra-records.json"); + await writeFile(recordsPath, JSON.stringify([{ name: "json", type: "A", value: "1.1.1.1" }])); + await writeFile( + path, + [ + "server_url: http://localhost:8080", + "dns:", + " extra_records_path: " + recordsPath, + " extra_records:", + " - name: yaml", + " type: A", + " value: 2.2.2.2", + ].join("\n"), + ); + + const config = await loadHeadscaleConfig(path); + expect(config.dnsRecords()).toEqual([{ name: "json", type: "A", value: "1.1.1.1" }]); + + await config.addDNS({ name: "new", type: "A", value: "3.3.3.3" }); + + expect(JSON.parse(await readFile(recordsPath, "utf8"))).toEqual([ + { name: "json", type: "A", value: "1.1.1.1" }, + { name: "new", type: "A", value: "3.3.3.3" }, + ]); + expect(parse(await readFile(path, "utf8")).dns.extra_records).toEqual([ + { name: "yaml", type: "A", value: "2.2.2.2" }, + ]); + }); + test("reads OIDC restrictions as empty arrays when unset", async () => { const path = join(dir, "config.yaml"); await writeFile(