clawdie-ai/setup/dns.test.ts

234 lines
8.2 KiB
TypeScript
Raw Normal View History

import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { makePlatformRegistryFixture } from '../src/test-fixtures/platform-registry.js';
import {
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
detectUpstream,
ingressFromConfig,
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
parseResolvConfNameservers,
parseUpstreamSpec,
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', () => {
const registry = makePlatformRegistryFixture();
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');
expect(result.content).toContain('address=/web.home.arpa/10.0.1.4');
expect(result.content).toContain('address=/alpha.home.arpa/10.0.1.4');
const hosts = result.records.map((record) => record.host);
expect(hosts).toEqual([
'ai.home.arpa',
'cms.home.arpa',
'web.home.arpa',
'git.home.arpa',
'alpha.home.arpa',
'blog.alpha.home.arpa',
]);
});
it('skips shared-role records when the role is not declared in shared.jails', () => {
const registry = makePlatformRegistryFixture();
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');
expect(result.content).toContain('web.home.arpa');
expect(result.content).toContain('alpha.home.arpa');
});
it('emits an address record per internal/public tenant site and skips disabled sites', () => {
const registry = makePlatformRegistryFixture();
const withSites = {
...registry,
tenants: {
...registry.tenants,
alpha: {
...registry.tenants.alpha!,
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);
expect(hosts).toContain('blog.alpha.home.arpa');
expect(hosts).toContain('docs.alpha.home.arpa');
expect(hosts).not.toContain('old.alpha.home.arpa');
});
it('honors a custom internal_base from the registry', () => {
const registry = makePlatformRegistryFixture();
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');
expect(result.content).toContain('address=/alpha.lan.example/10.0.1.4');
});
it('emits bind and server directives only when provided', () => {
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
const registry = makePlatformRegistryFixture();
const without = renderDnsmasqConfig(registry, INGRESS);
expect(without.content).not.toContain('listen-address=');
expect(without.content).not.toContain('bind-interfaces');
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
expect(without.content).not.toContain('server=');
expect(without.listenAddresses).toEqual([]);
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
expect(without.upstream).toEqual([]);
const withOptions = renderDnsmasqConfig(registry, INGRESS, {
listenAddresses: ['127.0.0.1', '10.0.1.1'],
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
upstream: ['1.1.1.1', '9.9.9.9'],
});
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']);
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
});
it('deduplicates bind and upstream entries', () => {
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
const registry = makePlatformRegistryFixture();
const result = renderDnsmasqConfig(registry, INGRESS, {
listenAddresses: ['127.0.0.1', '127.0.0.1', '10.0.1.1'],
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
upstream: ['1.1.1.1', '1.1.1.1', '9.9.9.9'],
});
expect(result.listenAddresses).toEqual(['127.0.0.1', '10.0.1.1']);
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +02:00
expect(result.upstream).toEqual(['1.1.1.1', '9.9.9.9']);
});
it('only rewrites the file when content changes', () => {
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-'));
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 });
});
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:49:57 +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',
});
});
});
Make setup/dns deploy and start dnsmasq on the host (Sam & Claude) The dns step previously rendered dnsmasq.conf to a project-local tmp/ path and stopped there — the platform classified dnsmasq as a shared service but never actually deployed, enabled, or started it. This is the dnsmasq blind spot. What changed: - On FreeBSD as root, the rendered config now lands at /usr/local/etc/dnsmasq.conf with a .bak of any prior content. - sysrc dnsmasq_enable=YES is set so the service comes up on boot. - service dnsmasq restart fires only when the config actually changed or the service is not currently running. Idempotent. - Off-host (tests, dev) the step still writes to tmp/dnsmasq.conf and skips all lifecycle ops, so unit tests and dry-runs are unchanged. Upstream resolvers were the other half of the gap — the previous config had `no-resolv` with no `server=` directive, making it unusable as a system resolver standalone. We now resolve upstream from, in priority order: 1. $DNSMASQ_UPSTREAM (comma- or space-separated; operator override) 2. non-loopback nameservers parsed from /etc/resolv.conf 3. 1.1.1.1 + 9.9.9.9 fallback Each step renders `server=<ip>` lines so dnsmasq can forward queries outside the local zone. Deliberately NOT changed: /etc/resolv.conf. Pointing the host at dnsmasq for system-wide resolution is a separate operator decision — mis-doing it can take a host offline. The structured status now emits SETUP_DNS with DEPLOYED/SYSRC_ENABLED/SERVICE state so /publishreport can surface where the resolver stands. --- 13 dns tests pass (up from 5). Pre-existing cms.test.ts failure in the wider setup/ run is unrelated to this change. --- Build: FAIL | Tests: FAIL — 16 failed
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);
});
});
});