diff --git a/CHANGELOG.md b/CHANGELOG.md index fc62272..107bf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled. - Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)). - AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)). +- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)). ### 0.5.10 (April 4, 2025) - Fix an issue where other preferences to skip onboarding affected every user. diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 73604f8..654b8ff 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -194,16 +194,16 @@ async function removeRecord(formData: FormData, context: LoadContext) { return data({ success: false }, 400); } - const records = config.dns.extra_records.filter( - (i) => i.name !== recordName || i.type !== recordType, - ); + // Value is not needed for removal + const restart = await context.hs.removeDNS({ + name: recordName, + type: recordType, + value: '', + }); - await context.hs.patch([ - { - path: 'dns.extra_records', - value: records, - }, - ]); + if (!restart) { + return; + } await context.integration?.onConfigChange(context.client); } @@ -218,15 +218,15 @@ async function addRecord(formData: FormData, context: LoadContext) { return data({ success: false }, 400); } - const records = config.dns.extra_records; - records.push({ name: recordName, type: recordType, value: recordValue }); + const restart = await context.hs.addDNS({ + name: recordName, + type: recordType, + value: recordValue, + }); - await context.hs.patch([ - { - path: 'dns.extra_records', - value: records, - }, - ]); + if (!restart) { + return; + } await context.integration?.onConfigChange(context.client); } diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 01834c9..ab2051d 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -44,7 +44,7 @@ export async function loader({ nameservers: config.dns.nameservers.global, splitDns: config.dns.nameservers.split, searchDomains: config.dns.search_domains, - extraRecords: config.dns.extra_records, + extraRecords: context.hs.d, }; return { diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index 74b6562..d1f7822 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -43,6 +43,7 @@ const headscaleConfig = type({ public_url: 'string.url?', config_path: 'string?', config_strict: stringToBool, + dns_records_path: 'string?', }).onDeepUndeclaredKey('reject'); const containerLabel = type({ diff --git a/app/server/headscale/config-dns.ts b/app/server/headscale/config-dns.ts new file mode 100644 index 0000000..713ca6c --- /dev/null +++ b/app/server/headscale/config-dns.ts @@ -0,0 +1,127 @@ +import { constants, access, readFile, writeFile } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import log from '~/utils/log'; + +export interface DNSRecord { + type: 'A' | 'AAAA' | (string & {}); + name: string; + value: string; +} + +// This class is solely for DNS records that are out of tree in the main +// Headscale config file. If you are using dns.extra_records_path, it will +// be managed here and not in the main config file. +// +// All DNS insertions and deletions are handled by the main config manager, +// but are passed through to here if the extra file is being used. +export class HeadscaleDNSConfig { + private records: DNSRecord[]; + private access: 'rw' | 'ro' | 'no'; + private path?: string; + private writeLock = false; + + constructor( + access: 'rw' | 'ro' | 'no', + records?: DNSRecord[], + path?: string, + ) { + this.access = access; + this.records = records ?? []; + this.path = path; + } + + readable() { + return this.access !== 'no'; + } + + writable() { + return this.access === 'rw'; + } + + get r() { + return this.records; + } + + async patch(records: DNSRecord[]) { + if (!this.path || !this.readable() || !this.writable()) { + return; + } + + this.records = records; + log.debug( + 'config', + 'Patching DNS records (%d -> %d)', + this.records.length, + records.length, + ); + + return this.write(); + } + + private async write() { + if (!this.path || !this.writable()) { + return; + } + + while (this.writeLock) { + await setTimeout(100); + } + + this.writeLock = true; + log.debug('config', 'Writing updated DNS configuration to %s', this.path); + const data = JSON.stringify(this.records, null, 4); + await writeFile(this.path, data); + this.writeLock = false; + } +} + +export async function loadHeadscaleDNS(path?: string) { + if (!path) { + return new HeadscaleDNSConfig('no'); + } + + log.debug('config', 'Loading Headscale DNS configuration file: %s', path); + const { w, r } = await validateConfigPath(path); + if (!r) { + return new HeadscaleDNSConfig('no'); + } + + const records = await loadConfigFile(path); + if (!records) { + return new HeadscaleDNSConfig('no'); + } + + return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path); +} + +async function validateConfigPath(path: string) { + try { + await access(path, constants.F_OK | constants.R_OK); + log.info('config', 'Found a valid Headscale DNS file at %s', path); + } catch (error) { + log.error('config', 'Unable to read a Headscale DNS file at %s', path); + log.error('config', '%s', error); + return { w: false, r: false }; + } + + try { + await access(path, constants.F_OK | constants.W_OK); + return { w: true, r: true }; + } catch (error) { + log.warn('config', 'Headscale DNS file at %s is not writable', path); + return { w: false, r: true }; + } +} + +async function loadConfigFile(path: string) { + log.debug('config', 'Reading Headscale DNS file at %s', path); + try { + const data = await readFile(path, 'utf8'); + const records = JSON.parse(data) as DNSRecord[]; + return records; + } catch (e) { + log.error('config', 'Error reading Headscale DNS file at %s', path); + log.error('config', '%s', e); + return false; + } +} diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index b4a9010..11fb05e 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -3,6 +3,7 @@ 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 { @@ -19,9 +20,11 @@ class HeadscaleConfig { private access: 'rw' | 'ro' | 'no'; private path?: string; private writeLock = false; + private dns?: HeadscaleDNSConfig; constructor( access: 'rw' | 'ro' | 'no', + dns?: HeadscaleDNSConfig, config?: typeof headscaleConfig.infer, document?: Document, path?: string, @@ -30,6 +33,7 @@ class HeadscaleConfig { this.config = config; this.document = document; this.path = path; + this.dns = dns; } readable() { @@ -44,6 +48,14 @@ class HeadscaleConfig { return this.config; } + get d() { + if (this.dns) { + return this.dns.r; + } + + return this.config?.dns.extra_records ?? []; + } + async patch(patches: PatchConfig[]) { if (!this.path || !this.document || !this.readable() || !this.writable()) { return; @@ -115,9 +127,106 @@ class HeadscaleConfig { this.writeLock = false; 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 records = this.dns.r; + if ( + records.some((i) => i.name === record.name && i.type === record.type) + ) { + log.debug('config', 'DNS record already exists'); + return; + } + + return this.dns.patch([...records, record]); + } + + // 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 ?? []; + if ( + existing.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; + } + + /** + * 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. + + if (this.dns) { + if (!this.dns.readable() || !this.dns.writable()) { + log.debug('config', 'DNS config is not writable'); + return; + } + + const records = this.dns.r.filter( + (i) => i.name !== record.name || i.type !== record.type, + ); + + 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.config?.dns.extra_records ?? []; + 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) { + return; + } + + await this.patch([ + { + path: 'dns.extra_records', + value: existing.filter( + (i) => i.name !== record.name || i.type !== record.type, + ), + }, + ]); + + return true; + } } -export async function loadHeadscaleConfig(path?: string, strict = true) { +export async function loadHeadscaleConfig( + path?: string, + strict = true, + dnsPath?: string, +) { if (!path) { log.debug('config', 'No Headscale configuration file was provided'); return new HeadscaleConfig('no'); @@ -137,6 +246,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true) { if (!strict) { return new HeadscaleConfig( w ? 'rw' : 'ro', + new HeadscaleDNSConfig('no'), augmentUnstrictConfig(document.toJSON()), document, path, @@ -148,7 +258,18 @@ export async function loadHeadscaleConfig(path?: string, strict = true) { return new HeadscaleConfig('no'); } - return new HeadscaleConfig(w ? 'rw' : 'ro', config, document, path); + if (config.dns.extra_records && config.dns.extra_records_path) { + 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); + return new HeadscaleConfig(w ? 'rw' : 'ro', dns, config, document, path); } async function validateConfigPath(path: string) { diff --git a/app/server/headscale/config-schema.ts b/app/server/headscale/config-schema.ts index 13b70b3..3c279ea 100644 --- a/app/server/headscale/config-schema.ts +++ b/app/server/headscale/config-schema.ts @@ -112,9 +112,8 @@ export const headscaleConfig = type({ name: 'string', value: 'string', type: 'string | "A"', - }) - .array() - .default(() => []), + }).array(), + extra_records_path: 'string?', }, unix_socket: 'string?', diff --git a/app/server/index.ts b/app/server/index.ts index fbf4231..fc3b8b5 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -37,6 +37,7 @@ const appLoadContext = { hs: await loadHeadscaleConfig( config.headscale.config_path, config.headscale.config_strict, + config.headscale.dns_records_path, ), // TODO: Better cookie options in config