feat: add support for extra_records_path in hs config

This commit is contained in:
Aarnav Tale 2025-04-26 13:25:14 -04:00
parent 6640170aee
commit c6acfdfa40
No known key found for this signature in database
8 changed files with 273 additions and 23 deletions

View File

@ -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.

View File

@ -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);
}

View File

@ -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 {

View File

@ -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({

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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?',

View File

@ -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