2026-04-24 19:11:37 +02:00
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
|
2026-05-03 20:58:27 +02:00
|
|
|
import { makePlatformRegistryFixture } from '../src/test-fixtures/platform-registry.js';
|
2026-04-24 19:11:37 +02:00
|
|
|
import {
|
2026-05-09 12:49:57 +02:00
|
|
|
detectUpstream,
|
2026-05-09 13:03:15 +02:00
|
|
|
ingressFromConfig,
|
2026-05-09 12:49:57 +02:00
|
|
|
parseResolvConfNameservers,
|
|
|
|
|
parseUpstreamSpec,
|
2026-04-24 19:11:37 +02:00
|
|
|
renderDnsmasqConfig,
|
|
|
|
|
writeDnsmasqConfig,
|
|
|
|
|
type DnsIngressMap,
|
|
|
|
|
} from './dns.js';
|
|
|
|
|
|
|
|
|
|
const INGRESS: DnsIngressMap = {
|
|
|
|
|
controlplane: '10.0.1.2',
|
|
|
|
|
cms: '10.0.1.4',
|
|
|
|
|
git: '10.0.1.6',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('setup/dns', () => {
|
|
|
|
|
it('renders address records for the platform controlplane, shared jails, and every tenant', () => {
|
2026-05-03 20:58:27 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
2026-04-24 19:11:37 +02:00
|
|
|
const result = renderDnsmasqConfig(registry, INGRESS);
|
|
|
|
|
|
|
|
|
|
expect(result.zone).toBe('home.arpa');
|
|
|
|
|
expect(result.content).toContain('domain=home.arpa');
|
|
|
|
|
expect(result.content).toContain('local=/home.arpa/');
|
|
|
|
|
expect(result.content).toContain('address=/ai.home.arpa/10.0.1.2');
|
|
|
|
|
expect(result.content).toContain('address=/cms.home.arpa/10.0.1.4');
|
|
|
|
|
expect(result.content).toContain('address=/git.home.arpa/10.0.1.6');
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(result.content).toContain('address=/web.home.arpa/10.0.1.4');
|
2026-05-07 11:16:40 +02:00
|
|
|
expect(result.content).toContain('address=/alpha.home.arpa/10.0.1.4');
|
2026-04-24 19:11:37 +02:00
|
|
|
|
|
|
|
|
const hosts = result.records.map((record) => record.host);
|
|
|
|
|
expect(hosts).toEqual([
|
|
|
|
|
'ai.home.arpa',
|
|
|
|
|
'cms.home.arpa',
|
2026-05-09 13:03:15 +02:00
|
|
|
'web.home.arpa',
|
2026-04-24 19:11:37 +02:00
|
|
|
'git.home.arpa',
|
2026-05-07 11:16:40 +02:00
|
|
|
'alpha.home.arpa',
|
|
|
|
|
'blog.alpha.home.arpa',
|
2026-04-24 19:11:37 +02:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('skips shared-role records when the role is not declared in shared.jails', () => {
|
2026-05-03 20:58:27 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
2026-04-24 19:11:37 +02:00
|
|
|
const stripped = {
|
|
|
|
|
...registry,
|
|
|
|
|
shared: { ...registry.shared, jails: [] },
|
|
|
|
|
};
|
|
|
|
|
const result = renderDnsmasqConfig(stripped, INGRESS);
|
|
|
|
|
expect(result.content).not.toContain('cms.home.arpa');
|
|
|
|
|
expect(result.content).not.toContain('git.home.arpa');
|
|
|
|
|
expect(result.content).toContain('ai.home.arpa');
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(result.content).toContain('web.home.arpa');
|
2026-05-07 11:16:40 +02:00
|
|
|
expect(result.content).toContain('alpha.home.arpa');
|
2026-04-24 19:11:37 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-24 19:29:57 +02:00
|
|
|
it('emits an address record per internal/public tenant site and skips disabled sites', () => {
|
2026-05-03 20:58:27 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
2026-04-24 19:29:57 +02:00
|
|
|
const withSites = {
|
|
|
|
|
...registry,
|
|
|
|
|
tenants: {
|
|
|
|
|
...registry.tenants,
|
2026-05-07 11:16:40 +02:00
|
|
|
alpha: {
|
|
|
|
|
...registry.tenants.alpha!,
|
2026-04-24 19:29:57 +02:00
|
|
|
sites: [
|
|
|
|
|
{ id: 'blog', exposure: 'internal' as const },
|
|
|
|
|
{ id: 'docs', exposure: 'public' as const },
|
|
|
|
|
{ id: 'old', exposure: 'disabled' as const },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const result = renderDnsmasqConfig(withSites, INGRESS);
|
|
|
|
|
const hosts = result.records.map((record) => record.host);
|
2026-05-07 11:16:40 +02:00
|
|
|
expect(hosts).toContain('blog.alpha.home.arpa');
|
|
|
|
|
expect(hosts).toContain('docs.alpha.home.arpa');
|
|
|
|
|
expect(hosts).not.toContain('old.alpha.home.arpa');
|
2026-04-24 19:29:57 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-24 19:11:37 +02:00
|
|
|
it('honors a custom internal_base from the registry', () => {
|
2026-05-03 20:58:27 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
2026-04-24 19:11:37 +02:00
|
|
|
const custom = {
|
|
|
|
|
...registry,
|
|
|
|
|
platform: {
|
|
|
|
|
...registry.platform,
|
|
|
|
|
internalBase: 'lan.example',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const result = renderDnsmasqConfig(custom, INGRESS);
|
|
|
|
|
expect(result.zone).toBe('lan.example');
|
|
|
|
|
expect(result.content).toContain('domain=lan.example');
|
|
|
|
|
expect(result.content).toContain('address=/ai.lan.example/10.0.1.2');
|
2026-05-07 11:16:40 +02:00
|
|
|
expect(result.content).toContain('address=/alpha.lan.example/10.0.1.4');
|
2026-04-24 19:11:37 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:03:15 +02:00
|
|
|
it('emits bind and server directives only when provided', () => {
|
2026-05-09 12:49:57 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
|
|
|
|
const without = renderDnsmasqConfig(registry, INGRESS);
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(without.content).not.toContain('listen-address=');
|
|
|
|
|
expect(without.content).not.toContain('bind-interfaces');
|
2026-05-09 12:49:57 +02:00
|
|
|
expect(without.content).not.toContain('server=');
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(without.listenAddresses).toEqual([]);
|
2026-05-09 12:49:57 +02:00
|
|
|
expect(without.upstream).toEqual([]);
|
|
|
|
|
|
2026-05-09 13:03:15 +02:00
|
|
|
const withOptions = renderDnsmasqConfig(registry, INGRESS, {
|
|
|
|
|
listenAddresses: ['127.0.0.1', '10.0.1.1'],
|
2026-05-09 12:49:57 +02:00
|
|
|
upstream: ['1.1.1.1', '9.9.9.9'],
|
|
|
|
|
});
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(withOptions.content).toContain('listen-address=127.0.0.1');
|
|
|
|
|
expect(withOptions.content).toContain('listen-address=10.0.1.1');
|
|
|
|
|
expect(withOptions.content).toContain('bind-interfaces');
|
|
|
|
|
expect(withOptions.content).toContain('server=1.1.1.1');
|
|
|
|
|
expect(withOptions.content).toContain('server=9.9.9.9');
|
|
|
|
|
expect(withOptions.listenAddresses).toEqual(['127.0.0.1', '10.0.1.1']);
|
|
|
|
|
expect(withOptions.upstream).toEqual(['1.1.1.1', '9.9.9.9']);
|
2026-05-09 12:49:57 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:03:15 +02:00
|
|
|
it('deduplicates bind and upstream entries', () => {
|
2026-05-09 12:49:57 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
|
|
|
|
const result = renderDnsmasqConfig(registry, INGRESS, {
|
2026-05-09 13:03:15 +02:00
|
|
|
listenAddresses: ['127.0.0.1', '127.0.0.1', '10.0.1.1'],
|
2026-05-09 12:49:57 +02:00
|
|
|
upstream: ['1.1.1.1', '1.1.1.1', '9.9.9.9'],
|
|
|
|
|
});
|
2026-05-09 13:03:15 +02:00
|
|
|
expect(result.listenAddresses).toEqual(['127.0.0.1', '10.0.1.1']);
|
2026-05-09 12:49:57 +02:00
|
|
|
expect(result.upstream).toEqual(['1.1.1.1', '9.9.9.9']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 19:11:37 +02:00
|
|
|
it('only rewrites the file when content changes', () => {
|
2026-05-03 20:58:27 +02:00
|
|
|
const registry = makePlatformRegistryFixture();
|
|
|
|
|
const base = path.join(process.cwd(), 'tmp', 'tests');
|
|
|
|
|
fs.mkdirSync(base, { recursive: true });
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(base, 'dns-test-'));
|
2026-04-24 19:11:37 +02:00
|
|
|
const outputPath = path.join(dir, 'dnsmasq.conf');
|
|
|
|
|
|
|
|
|
|
const first = writeDnsmasqConfig(outputPath, registry, INGRESS);
|
|
|
|
|
expect(first.changed).toBe(true);
|
|
|
|
|
const firstStat = fs.statSync(outputPath).mtimeMs;
|
|
|
|
|
|
|
|
|
|
const second = writeDnsmasqConfig(outputPath, registry, INGRESS);
|
|
|
|
|
expect(second.changed).toBe(false);
|
|
|
|
|
expect(fs.statSync(outputPath).mtimeMs).toBe(firstStat);
|
|
|
|
|
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
});
|
2026-05-09 12:49:57 +02:00
|
|
|
|
2026-05-09 13:03:15 +02:00
|
|
|
describe('ingressFromConfig', () => {
|
|
|
|
|
it('uses deploy-time addresses from env-file values', () => {
|
|
|
|
|
expect(
|
|
|
|
|
ingressFromConfig({
|
|
|
|
|
WARDEN_SUBNET_BASE: '192.168.72',
|
|
|
|
|
WARDEN_GATEWAY: '192.168.72.1',
|
|
|
|
|
WARDEN_CMS_IP: '192.168.72.3',
|
|
|
|
|
WARDEN_GIT_IP: '192.168.72.2',
|
|
|
|
|
}),
|
|
|
|
|
).toEqual({
|
|
|
|
|
controlplane: '192.168.72.1',
|
|
|
|
|
cms: '192.168.72.3',
|
|
|
|
|
git: '192.168.72.2',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 12:49:57 +02:00
|
|
|
describe('parseResolvConfNameservers', () => {
|
|
|
|
|
it('extracts ipv4 nameservers and skips loopback addresses', () => {
|
|
|
|
|
const raw = [
|
|
|
|
|
'# comment line',
|
|
|
|
|
'nameserver 1.1.1.1',
|
|
|
|
|
'nameserver 127.0.0.1',
|
|
|
|
|
'nameserver 8.8.8.8 # trailing comment',
|
|
|
|
|
'nameserver ::1',
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
expect(parseResolvConfNameservers(raw)).toEqual(['1.1.1.1', '8.8.8.8']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns an empty list when no usable entries are present', () => {
|
|
|
|
|
expect(parseResolvConfNameservers('search example\n')).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('parseUpstreamSpec', () => {
|
|
|
|
|
it('splits comma- and whitespace-separated entries', () => {
|
|
|
|
|
expect(parseUpstreamSpec('1.1.1.1, 9.9.9.9 8.8.8.8')).toEqual([
|
|
|
|
|
'1.1.1.1',
|
|
|
|
|
'9.9.9.9',
|
|
|
|
|
'8.8.8.8',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('detectUpstream', () => {
|
|
|
|
|
it('prefers env override when set', () => {
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(process.cwd(), 'tmp', 'dns-test-resolv-'));
|
|
|
|
|
const resolv = path.join(dir, 'resolv.conf');
|
|
|
|
|
fs.writeFileSync(resolv, 'nameserver 8.8.8.8\n');
|
|
|
|
|
try {
|
|
|
|
|
expect(
|
|
|
|
|
detectUpstream({ envValue: '1.0.0.1', resolvConfPath: resolv }),
|
|
|
|
|
).toEqual(['1.0.0.1']);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to resolv.conf when env is empty', () => {
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(process.cwd(), 'tmp', 'dns-test-resolv-'));
|
|
|
|
|
const resolv = path.join(dir, 'resolv.conf');
|
|
|
|
|
fs.writeFileSync(resolv, 'nameserver 1.1.1.1\nnameserver 127.0.0.1\n');
|
|
|
|
|
try {
|
|
|
|
|
expect(
|
|
|
|
|
detectUpstream({ envValue: '', resolvConfPath: resolv }),
|
|
|
|
|
).toEqual(['1.1.1.1']);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to public defaults when nothing else is available', () => {
|
|
|
|
|
const result = detectUpstream({
|
|
|
|
|
envValue: '',
|
|
|
|
|
resolvConfPath: '/nonexistent/resolv.conf',
|
|
|
|
|
});
|
|
|
|
|
expect(result.length).toBeGreaterThan(0);
|
|
|
|
|
// First entry must be a routable IP (not loopback).
|
|
|
|
|
expect(result[0]!.startsWith('127.')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-24 19:11:37 +02:00
|
|
|
});
|