clawdie-ai/setup/dns.ts
Operator & Codex 149d90196f Fix dnsmasq deploy address handling (C&C)
---
Build: pass | Tests: pass — 2247 passed (666 files)
2026-05-09 13:03:15 +02:00

361 lines
11 KiB
TypeScript

/**
* Step: dns — Configure dnsmasq as the platform's internal resolver for the
* `<internal_base>` zone (`<label>.<internal_base>` and `<tenant>.<internal_base>`).
*
* mDNS/Avahi cannot resolve `<site>.<tenant>.<base>` multi-label names reliably,
* so the host runs dnsmasq locally. The zone is derived strictly from
* `infra/tenants.yaml`; callers pass the per-role ingress IPs that the rest of
* setup already commits to.
*
* On FreeBSD, running as root, this step deploys the rendered config to
* `/usr/local/etc/dnsmasq.conf` (backing up any prior content), runs
* `sysrc dnsmasq_enable=YES`, and restarts the service when the config
* actually changed. Off-host (tests, dev), it falls back to writing
* `tmp/dnsmasq.conf` and skips lifecycle ops.
*
* Upstream resolvers come from `$DNSMASQ_UPSTREAM` (comma- or space-separated),
* else from non-loopback nameservers in `/etc/resolv.conf`, else from
* 1.1.1.1 + 9.9.9.9. The platform does NOT update `/etc/resolv.conf` itself —
* pointing the host at dnsmasq is a separate operator decision.
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import {
platformServiceDomain,
siteFqdn,
tenantInternalDomain,
} from '../src/platform-layout.js';
import {
loadTenantRegistry,
type PlatformRegistry,
} from '../src/tenant-registry.js';
import { getPlatform, isRoot } from './platform.js';
import { emitStatus } from './status.js';
const HOST_DNSMASQ_CONF = '/usr/local/etc/dnsmasq.conf';
const FALLBACK_UPSTREAM = ['1.1.1.1', '9.9.9.9'];
export interface DnsIngressMap {
/** Controlplane (`ai.<base>`) IP. */
controlplane: string;
/** Shared CMS / publishing ingress (`cms.<base>`, tenant service hostnames). */
cms: string;
/** Shared code service (`git.<base>`) IP. */
git: string;
}
export interface DnsRenderOptions {
/** Addresses dnsmasq should bind to. */
listenAddresses?: string[];
/**
* Upstream resolvers dnsmasq forwards non-local queries to. When omitted or
* empty, dnsmasq still answers the platform's `<internal_base>` zone but has
* no fallback for everything else.
*/
upstream?: string[];
}
export interface DnsRenderResult {
zone: string;
records: Array<{ host: string; ip: string }>;
listenAddresses: string[];
upstream: string[];
content: string;
}
export function parseResolvConfNameservers(raw: string): string[] {
return raw
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => /^nameserver\s+\S+/u.test(line))
.map((line) => line.replace(/^nameserver\s+/u, '').split(/\s+/u)[0]!.trim())
.filter((ip) => Boolean(ip) && !/^127\./u.test(ip) && ip !== '::1');
}
export function parseUpstreamSpec(raw: string): string[] {
return raw
.split(/[,\s]+/u)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function detectUpstream(opts: {
envValue?: string;
resolvConfPath?: string;
}): string[] {
if (opts.envValue && opts.envValue.trim()) {
const fromEnv = parseUpstreamSpec(opts.envValue);
if (fromEnv.length > 0) return fromEnv;
}
const resolvPath = opts.resolvConfPath ?? '/etc/resolv.conf';
try {
const raw = fs.readFileSync(resolvPath, 'utf-8');
const fromResolv = parseResolvConfNameservers(raw);
if (fromResolv.length > 0) return fromResolv;
} catch {
// best-effort
}
return [...FALLBACK_UPSTREAM];
}
function canonicalBase(base: string): string {
return base.trim().replace(/^\.+|\.+$/g, '') || 'home.arpa';
}
export function renderDnsmasqConfig(
registry: PlatformRegistry,
ingress: DnsIngressMap,
options: DnsRenderOptions = {},
): DnsRenderResult {
const base = canonicalBase(registry.platform.internalBase);
const records: Array<{ host: string; ip: string }> = [];
const addRecord = (host: string, ip: string): void => {
if (!records.some((record) => record.host === host && record.ip === ip)) {
records.push({ host, ip });
}
};
addRecord(platformServiceDomain('ai', base), ingress.controlplane);
const sharedRoles: Array<[string, string]> = [
['cms', ingress.cms],
['web', ingress.cms],
['git', ingress.git],
];
for (const [role, ip] of sharedRoles) {
if (
role === 'web' ||
registry.shared.jails.some(
(jail) => jail.trim().toLowerCase() === role,
)
) {
addRecord(platformServiceDomain(role, base), ip);
}
}
for (const tenant of Object.values(registry.tenants).sort((a, b) =>
a.id.localeCompare(b.id),
)) {
addRecord(tenantInternalDomain(tenant.id, base), ingress.cms);
for (const site of tenant.sites) {
if (site.exposure === 'disabled') continue;
const fqdn = siteFqdn(site.id, tenant.id, 'internal', base);
if (fqdn) {
addRecord(fqdn, ingress.cms);
}
}
}
const listenAddresses = (options.listenAddresses ?? []).filter(
(entry, index, all) => entry && all.indexOf(entry) === index,
);
const upstream = (options.upstream ?? []).filter(
(entry, index, all) => entry && all.indexOf(entry) === index,
);
const lines = [
`# Generated from infra/tenants.yaml — do not edit by hand.`,
`# Local resolver for the '${base}' zone; mDNS cannot serve multi-label`,
`# names like '<site>.<tenant>.${base}', so dnsmasq answers the zone.`,
``,
`port=53`,
`domain=${base}`,
`local=/${base}/`,
`domain-needed`,
`bogus-priv`,
`no-resolv`,
`no-hosts`,
...listenAddresses.map((addr) => `listen-address=${addr}`),
...(listenAddresses.length > 0 ? [`bind-interfaces`] : []),
``,
...records.map((record) => `address=/${record.host}/${record.ip}`),
``,
];
if (upstream.length > 0) {
lines.push('# Upstream resolvers for everything outside the local zone.');
for (const server of upstream) {
lines.push(`server=${server}`);
}
lines.push('');
}
return {
zone: base,
records,
listenAddresses,
upstream,
content: lines.join('\n'),
};
}
export interface DnsWriteResult extends DnsRenderResult {
outputPath: string;
changed: boolean;
}
export function writeDnsmasqConfig(
outputPath: string,
registry: PlatformRegistry,
ingress: DnsIngressMap,
options: DnsRenderOptions = {},
): DnsWriteResult {
const rendered = renderDnsmasqConfig(registry, ingress, options);
const existing = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, 'utf-8')
: null;
const changed = existing !== rendered.content;
if (changed) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (existing !== null && existing.length > 0) {
const backup = `${outputPath}.bak`;
try {
fs.writeFileSync(backup, existing);
} catch {
// best-effort backup; do not block deploy
}
}
fs.writeFileSync(outputPath, rendered.content, 'utf-8');
}
return { ...rendered, outputPath, changed };
}
function envValue(env: Record<string, string>, key: string): string {
return process.env[key] || env[key] || '';
}
export function ingressFromConfig(env: Record<string, string>): DnsIngressMap {
const subnetBase = (
envValue(env, 'AGENT_SUBNET_BASE') ||
envValue(env, 'WARDEN_SUBNET_BASE') ||
'10.0.1'
).replace(/\.+$/u, '');
return {
controlplane:
envValue(env, 'CONTROLPLANE_HOST_IP') ||
envValue(env, 'WARDEN_GATEWAY') ||
`${subnetBase}.1`,
cms: envValue(env, 'WARDEN_CMS_IP') || `${subnetBase}.3`,
git: envValue(env, 'WARDEN_GIT_IP') || `${subnetBase}.2`,
};
}
export function ingressFromEnv(): DnsIngressMap {
return ingressFromConfig(
readEnvFile([
'AGENT_SUBNET_BASE',
'WARDEN_SUBNET_BASE',
'WARDEN_GATEWAY',
'CONTROLPLANE_HOST_IP',
'WARDEN_CMS_IP',
'WARDEN_GIT_IP',
]),
);
}
function isServiceRunning(name: string): boolean {
const result = spawnSync('service', [name, 'onestatus'], { stdio: 'ignore' });
return (result.status ?? 1) === 0;
}
function deployToHost(opts: {
outputPath: string;
}): { sysrcSet: boolean; restarted: boolean; started: boolean } {
// Idempotent: sysrc is safe to repeat; service restart triggers only when
// we actually need it (config changed or service is not currently running).
let sysrcSet = false;
try {
execSync('sysrc dnsmasq_enable=YES', { stdio: 'ignore' });
execSync(`sysrc dnsmasq_conf=${JSON.stringify(opts.outputPath)}`, { stdio: 'ignore' });
sysrcSet = true;
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
'sysrc dnsmasq_enable failed',
);
}
return { sysrcSet, restarted: false, started: false };
}
export async function run(args: string[]): Promise<void> {
const outIndex = args.indexOf('--out');
const explicitOut = outIndex >= 0 ? args[outIndex + 1] : undefined;
const skipDeploy = args.includes('--no-deploy');
const onFreeBSDRoot = getPlatform() === 'freebsd' && isRoot();
const outputPath =
explicitOut ??
(onFreeBSDRoot ? HOST_DNSMASQ_CONF : 'tmp/dnsmasq.conf');
const registry = loadTenantRegistry();
const upstream = detectUpstream({ envValue: process.env.DNSMASQ_UPSTREAM });
const ingress = ingressFromEnv();
const result = writeDnsmasqConfig(outputPath, registry, ingress, {
listenAddresses:
onFreeBSDRoot && outputPath === HOST_DNSMASQ_CONF
? ['127.0.0.1', ingress.controlplane]
: [],
upstream,
});
let sysrcSet = false;
let restarted = false;
let started = false;
let warning: string | null = null;
if (onFreeBSDRoot && !skipDeploy && outputPath === HOST_DNSMASQ_CONF) {
const deploy = deployToHost({ outputPath });
sysrcSet = deploy.sysrcSet;
const wasRunning = isServiceRunning('dnsmasq');
try {
if (result.changed || !wasRunning) {
execSync('service dnsmasq restart', { stdio: 'ignore' });
if (wasRunning) {
restarted = true;
} else {
started = true;
}
}
} catch (err) {
warning =
err instanceof Error ? err.message.split('\n')[0] : String(err);
logger.warn({ warning }, 'service dnsmasq restart failed');
}
}
console.log('');
console.log(`△ DNS (${result.zone})`);
console.log(` file: ${path.relative(process.cwd(), result.outputPath)}`);
for (const record of result.records) {
console.log(` ${record.host.padEnd(32)} ${record.ip}`);
}
if (result.listenAddresses.length > 0) {
console.log(` listen: ${result.listenAddresses.join(', ')}`);
}
if (result.upstream.length > 0) {
console.log(` upstream: ${result.upstream.join(', ')}`);
}
console.log(` ${result.changed ? 'wrote' : 'unchanged'}`);
console.log('');
emitStatus('SETUP_DNS', {
STATUS: warning ? 'warn' : 'success',
ZONE: result.zone,
OUTPUT: outputPath,
CHANGED: result.changed ? 'yes' : 'no',
RECORDS: String(result.records.length),
LISTEN: result.listenAddresses.join(',') || '-',
UPSTREAM: result.upstream.join(',') || '-',
DEPLOYED: onFreeBSDRoot && !skipDeploy && outputPath === HOST_DNSMASQ_CONF
? 'yes'
: 'no',
SYSRC_ENABLED: sysrcSet ? 'yes' : 'no',
SERVICE: started ? 'started' : restarted ? 'restarted' : 'unchanged',
...(warning ? { WARNING: warning } : {}),
});
}