361 lines
11 KiB
TypeScript
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 } : {}),
|
|
});
|
|
}
|