import { access, constants, 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; } 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 { 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; } }