merge dc/DC-146 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:47:32 -07:00
commit c12bff597b
2 files changed
+1064

No files matched your search

+575
View File
@@ -0,0 +1,575 @@
/**
* Technitium DNS Server integration (DC-146, specs/PRIVATE-NETWORK-SPEC.md §6).
*
* Two layers:
*
* 1. createTechnitiumClient({ baseUrl, getToken, fetchImpl, logger, timeoutMs })
* Thin HTTP client over the Technitium HTTP API (default
* http://<host>:5380/api). Token auth via credential-manager
* (key 'technitium' by default — host+token are CONFIG, never hardcoded).
* All calls go through a fetchT-style timeout wrapper (default 10s) and
* every failure surfaces as ProviderError with a machine-readable code
* (auth_failed | network_error | not_found | api_error).
*
* 2. createTechnitiumService({ client, credentialManager, registry, ... })
* Feature-facing orchestration:
* - syncRecords({zone, resources, devices, providerMode}) — idempotent
* upserts (by name+type) of per-resource / per-device A/AAAA records.
* Never throws wholesale: one bad record is reported as
* {action:'failed', error} while the rest proceed.
* - verifyResolution({name, type, expectedIp, dnsServer}) — independent
* DNS-over-UDP resolve (node dgram; dns.resolve4 cannot target a
* specific server per call). Falls back to a getRecord API read-back
* (noted via method:'api-readback') when UDP is unavailable.
* - configureSplitDns({zone, dnsServerIp}) — delegates to the ACTIVE
* provider ONLY when capabilities().splitDnsApi is true (Tailscale);
* WireGuard mode returns
* {configured:false, reason:'splitDnsApi unsupported — set DNS in client config'}
* per spec §8.3 (client config carries 10.53.0.1 instead).
* - checkSetup({zone}) — {apiReachable, tokenValid, zoneExists} for the
* setup-wizard connectivity step (DC-152).
*
* Hermetic by construction: fetchImpl / credentialManager / logger / registry
* are all injectable; tests never touch a real Technitium instance.
*/
'use strict';
const dgram = require('dgram');
const { ProviderError } = require('./errors');
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_CREDENTIAL_KEY = 'technitium';
const DEFAULT_PORT = 5380;
/** Query-type numbers for the DNS-over-UDP verifier. */
const DNS_QTYPE = { A: 1, AAAA: 28 };
/* ------------------------------------------------------------------ *
* Client
* ------------------------------------------------------------------ */
/** Classify a status/error-message pair into a ProviderError code. */
function classifyStatus(status, msg) {
const lower = msg.toLowerCase();
if (status === 401 || status === 403) return 'auth_failed';
if (lower.includes('unauthorized') || lower.includes('forbidden') ||
(lower.includes('token') && (lower.includes('invalid') || lower.includes('denied')))) return 'auth_failed';
if (lower.includes('not found') || lower.includes('does not exist') || lower.includes('no record')) return 'not_found';
return 'api_error';
}
/**
* Map a fetch/transport/HTTP/API failure to a ProviderError.
* Shared by every client call so callers only ever see {code} errors.
*/
function toTechnitiumError(err) {
if (err instanceof ProviderError) return err;
const msg = (err && err.message) || String(err);
const status = err && typeof err.httpStatus === 'number' ? err.httpStatus : null;
if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
return new ProviderError(`Technitium request timed out: ${msg}`, {
code: 'network_error',
cause: err,
});
}
if (status === 401 || status === 403) {
return new ProviderError(`Technitium auth failed (${status}): ${msg}`, { code: 'auth_failed', cause: err });
}
if (status !== null && status >= 500) {
return new ProviderError(`Technitium server error (${status}): ${msg}`, { code: 'api_error', cause: err });
}
if (err instanceof Error && err.name === 'FetchError') {
return new ProviderError(`Technitium unreachable: ${msg}`, { code: 'network_error', cause: err });
}
const code = status !== null ? classifyStatus(status, msg) : 'network_error';
const prefix = code === 'network_error' ? 'Technitium network error' : 'Technitium API error';
return new ProviderError(`${prefix}: ${msg}`, { code, cause: err });
}
/**
* Extract the credential token. Supports both a plain string secret and
* { token } objects so operators can store host+token together.
*/
function extractToken(stored) {
if (!stored) return null;
if (typeof stored === 'string') return stored;
if (typeof stored === 'object' && typeof stored.token === 'string') return stored.token;
return null;
}
/**
* @param {object} opts
* @param {string} [opts.baseUrl] e.g. 'http://10.53.0.1:5380/api'
* @param {string} [opts.host] host when baseUrl is omitted (port 5380)
* @param {Function} [opts.getToken] async () => token string (or null)
* @param {Function} [opts.fetchImpl] fetch-compatible (default global fetch)
* @param {object} [opts.logger]
* @param {number} [opts.timeoutMs] per-request timeout, default 10000
*/
function createTechnitiumClient(opts = {}) {
const {
baseUrl,
host,
getToken,
fetchImpl = (typeof fetch === 'function' ? fetch : null),
timeoutMs = DEFAULT_TIMEOUT_MS,
} = opts;
if (!baseUrl && !host) {
throw new ProviderError('Technitium client requires baseUrl or host (config-supplied, never hardcoded)', {
code: 'bad_input',
});
}
if (typeof fetchImpl !== 'function') {
throw new ProviderError('Technitium client requires a fetch implementation', { code: 'bad_input' });
}
const base = (baseUrl || `http://${host}:${DEFAULT_PORT}/api`).replace(/\/+$/, '');
/** fetchT-style timeout wrapper: races the call against a timer. */
async function callWithTimeout(url, init) {
let timer = null;
try {
return await Promise.race([
Promise.resolve(fetchImpl(url, init)),
new Promise((_, reject) => {
timer = setTimeout(() => {
const e = new Error(`timeout after ${timeoutMs}ms`);
e.name = 'TimeoutError';
reject(e);
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* One API round-trip. GET → query string; POST → JSON body. Token rides
* along on every call. Normalizes Technitium's `{status, response,
* errorMessage}` envelope into { ok, payload, errorMessage }.
*/
async function api(method, path, params = {}) {
const token = (await getToken()) || '';
const url = new URL(base + path);
const init = { method };
if (method === 'GET') {
url.search = new URLSearchParams({ ...params, token }).toString();
} else {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify({ ...params, token });
}
let res;
try {
res = await callWithTimeout(url.toString(), init);
} catch (err) {
throw toTechnitiumError(err);
}
let body = null;
try {
body = await res.json();
} catch (_err) {
body = null;
}
if (res.status === 401 || res.status === 403) {
const e = new ProviderError(`Technitium auth failed (HTTP ${res.status})`, { code: 'auth_failed' });
throw e;
}
if (!res.ok) {
const e = new ProviderError(
`Technitium API error (HTTP ${res.status}): ${(body && body.errorMessage) || res.statusText || ''}`,
{ code: 'api_error' }
);
e.httpStatus = res.status;
throw e;
}
// Technitium envelope: {status:'ok'|'error', response, errorMessage}
if (body && typeof body === 'object' && 'status' in body) {
if (body.status === 'error') {
const msg = body.errorMessage || 'unknown Technitium error';
const lower = String(msg).toLowerCase();
let code = 'api_error';
if (lower.includes('not found') || lower.includes('does not exist') || lower.includes('no record')) {
code = 'not_found';
} else if (lower.includes('unauthorized') || lower.includes('token') || lower.includes('forbidden')) {
code = 'auth_failed';
}
throw new ProviderError(`Technitium: ${msg}`, { code });
}
return body.response;
}
return body;
}
return {
baseUrl: base,
/** GET /zones/list → array of {name, type, ...} */
async listZones() {
const zones = await api('GET', '/zones/list');
if (Array.isArray(zones)) return zones;
if (zones && Array.isArray(zones.zones)) return zones.zones;
return [];
},
/**
* Create the zone as Primary if absent (idempotent).
* @returns {Promise<{created:boolean, unchanged:boolean}>}
*/
async ensureZone(name) {
if (!name) throw new ProviderError('ensureZone: name is required', { code: 'bad_input' });
const zones = await this.listZones();
const exists = zones.some((z) => String(z.name).toLowerCase() === String(name).toLowerCase());
if (exists) return { created: false, unchanged: true };
await api('POST', '/zones/create', { name, type: 'Primary' });
return { created: true, unchanged: false };
},
/**
* GET /zones/records/get → record object or null when absent.
* Technitium answers status:'error' + "Record not found" → not_found → null.
*/
async getRecord(zone, name, type) {
try {
const rec = await api('GET', '/zones/records/get', { zone, name, type });
if (Array.isArray(rec)) {
if (rec.length) return rec[0];
return null;
}
return rec || null;
} catch (err) {
if (err.code === 'not_found') return null;
throw err;
}
},
/**
* Idempotent upsert by name+type:
* absent → add → {created:true}
* present, same IP → no-op → {unchanged:true}
* present, differs → delete + add → {updated:true}
*/
async upsertRecord({ zone, name, type = 'A', rData }) {
if (!zone || !name || !type || !rData) {
throw new ProviderError('upsertRecord: zone, name, type and rData are required', { code: 'bad_input' });
}
const existing = await this.getRecord(zone, name, type);
const sameIp =
existing &&
String((existing.rData && existing.rData.ipAddress) || existing.ipAddress || '').toLowerCase() ===
String(rData.ipAddress).toLowerCase();
if (existing && sameIp) return { unchanged: true };
if (existing) {
await api('POST', '/zones/records/delete', { zone, name, type });
}
// rData fields are flattened into the add payload (ipAddress for A/AAAA).
await api('POST', '/zones/records/add', { zone, name, type, ...rData });
return existing ? { updated: true } : { created: true };
},
};
}
/* ------------------------------------------------------------------ *
* DNS-over-UDP verifier (dns.resolve* cannot target a specific server)
* ------------------------------------------------------------------ */
/** Encode a DNS query packet: header + QNAME + QTYPE/QCLASS. */
function buildDnsQuery(name, qtype) {
const labels = String(name).split('.').filter(Boolean);
// QNAME (sum of 1+len per label + root) + QTYPE + QCLASS
const qlen = labels.reduce((n, l) => n + l.length + 1, 0) + 1 + 4;
const buf = Buffer.alloc(12 + qlen);
const id = Math.floor(Math.random() * 0xffff);
buf.writeUInt16BE(id, 0);
buf.writeUInt16BE(0x0100, 2); // RD=1
buf.writeUInt16BE(1, 4); // QDCOUNT
let off = 12;
for (const label of labels) {
buf[off++] = label.length;
buf.write(label, off, 'ascii');
off += label.length;
}
buf[off++] = 0;
buf.writeUInt16BE(qtype, off); off += 2;
buf.writeUInt16BE(1, off); // IN
return { buf, id };
}
/** Parse answers, returning string IPs for qtype records (follows CNAMEs' rdata lengths). */
function parseDnsAnswer(msg, qtype) {
const qdcount = msg.readUInt16BE(4);
const ancount = msg.readUInt16BE(6);
let off = 12;
const skipName = () => {
while (off < msg.length) {
const len = msg[off];
if (len === 0) { off += 1; return; }
if ((len & 0xc0) === 0xc0) { off += 2; return; } // compression pointer
off += 1 + len;
}
};
for (let i = 0; i < qdcount; i++) { skipName(); off += 4; }
const ips = [];
for (let i = 0; i < ancount && off + 12 <= msg.length; i++) {
skipName();
const type = msg.readUInt16BE(off);
const rdlen = msg.readUInt16BE(off + 8);
const rdata = msg.slice(off + 10, off + 10 + rdlen);
if (type === 1 && qtype === DNS_QTYPE.A && rdlen === 4) {
ips.push(Array.from(rdata).join('.'));
} else if (type === 28 && qtype === DNS_QTYPE.AAAA && rdlen === 16) {
ips.push(Array.from({ length: 8 }, (_, g) => rdata.readUInt16BE(g * 2).toString(16)).join(':'));
}
off += 10 + rdlen;
}
return ips;
}
/**
* Resolve name@server over UDP. Rejects on timeout/parse failure.
* (dns.resolve4 has no per-call server override in Node, hence dgram.)
*/
function resolveOverUdp(server, name, type = 'A', timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const qtype = DNS_QTYPE[type.toUpperCase()] || DNS_QTYPE.A;
const { buf, id } = buildDnsQuery(name, qtype);
const sock = dgram.createSocket('udp4');
let settled = false;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { sock.close(); } catch (_e) { /* already closed */ }
fn(arg);
};
const timer = setTimeout(() => finish(reject, new Error('UDP resolve timeout')), timeoutMs);
sock.on('error', (err) => finish(reject, err));
sock.on('message', (msg) => {
if (msg.readUInt16BE(0) !== id) return; // stale/off-topic reply
try {
const rcode = msg.readUInt16BE(2) & 0x0f;
if (rcode !== 0) return finish(reject, new Error(`DNS rcode ${rcode}`));
const ips = parseDnsAnswer(msg, qtype);
if (!ips.length) return finish(reject, new Error('empty answer'));
finish(resolve, ips);
} catch (err) {
finish(reject, err);
}
});
sock.send(buf, 53, server, (err) => {
if (err) finish(reject, err);
});
});
}
/* ------------------------------------------------------------------ *
* Service
* ------------------------------------------------------------------ */
function nullLogger() {
return { info() {}, warn() {}, error() {}, debug() {} };
}
/** Pick the IP for a resource/device given the active provider mode. */
function pickIp(entry, providerMode) {
if (!entry) return null;
if (providerMode === 'tailscale') {
return entry.tailnetIp || (Array.isArray(entry.addresses) && entry.addresses[0]) || entry.ip || null;
}
// wireguard: 10.53.x.y client-pool address
return entry.ip || (Array.isArray(entry.addresses) && entry.addresses[0]) || entry.tailnetIp || null;
}
/**
* @param {object} opts
* @param {object} [opts.client] createTechnitiumClient result
* @param {object} [opts.credentialManager] credential-manager singleton pattern
* @param {string} [opts.credentialKey='technitium']
* @param {object} [opts.config] { technitium: { host | baseUrl } }
* @param {Function} [opts.fetchImpl]
* @param {Function} [opts.getActiveProvider] defaults to registry.getActiveProvider
* @param {object} [opts.registry] network registry (DC-140)
* @param {object} [opts.logger]
* @param {number} [opts.timeoutMs]
*/
function createTechnitiumService(opts = {}) {
const {
client,
credentialManager,
credentialKey = DEFAULT_CREDENTIAL_KEY,
config,
fetchImpl,
getActiveProvider,
registry,
logger = nullLogger(),
timeoutMs = DEFAULT_TIMEOUT_MS,
} = opts;
const resolveActiveProvider = getActiveProvider ||
(registry ? () => registry.getActiveProvider() : null);
let tc = client;
if (!tc) {
const tconf = (config && config.technitium) || {};
tc = createTechnitiumClient({
baseUrl: tconf.baseUrl,
host: tconf.host,
fetchImpl,
timeoutMs,
getToken: async () => {
if (!credentialManager || typeof credentialManager.retrieve !== 'function') return null;
const stored = await credentialManager.retrieve(credentialKey);
return extractToken(stored);
},
});
}
/**
* Sync A/AAAA records for resources + devices into `zone`.
* Per-record isolation: every entry gets its own result row; a failure
* never aborts the batch.
*/
async function syncRecords({ zone, resources = [], devices = [], providerMode } = {}) {
if (!zone) throw new ProviderError('syncRecords: zone is required', { code: 'bad_input' });
const wanted = [];
for (const r of resources) {
const ip = pickIp(r, providerMode);
if (r && r.name && ip) wanted.push({ name: r.name, fqdn: `${r.name}.${zone}`, ip, type: r.type || 'A' });
}
for (const d of devices) {
const ip = pickIp(d, providerMode);
if (d && (d.slug || d.name) && ip) {
const slug = d.slug || d.name;
wanted.push({ name: slug, fqdn: `${slug}.${zone}`, ip, type: d.type || 'A' });
}
}
const results = [];
for (const w of wanted) {
try {
const out = await tc.upsertRecord({ zone, name: w.name, type: w.type, rData: { ipAddress: w.ip } });
results.push({ name: w.fqdn, action: out.created ? 'created' : out.updated ? 'updated' : 'unchanged' });
} catch (err) {
logger.warn('technitium syncRecords record failed', { name: w.fqdn, code: err.code });
results.push({ name: w.fqdn, action: 'failed', error: `${err.code}: ${err.message}` });
}
}
return { zone, providerMode, count: results.length, results };
}
/**
* Independent resolve check. Prefers a real DNS-over-UDP query against
* dnsServer; falls back to an API getRecord read-back (method noted).
*/
async function verifyResolution({ name, type = 'A', expectedIp, dnsServer } = {}) {
if (!name || !expectedIp) {
throw new ProviderError('verifyResolution: name and expectedIp are required', { code: 'bad_input' });
}
if (dnsServer) {
try {
const ips = await resolveOverUdp(dnsServer, name, type);
const ok = ips.map((i) => i.toLowerCase()).includes(String(expectedIp).toLowerCase());
return { name, type, expectedIp, method: 'dns-udp', resolved: ips, verified: ok };
} catch (err) {
logger.debug('technitium UDP verify failed; falling back to API read-back', { name, error: err.message });
}
}
// Fallback: read the record back through the API (weaker — bypasses the
// resolver — but hermetic and always available). Note it explicitly.
const shortName = String(name).split('.')[0];
const zone = String(name).split('.').slice(1).join('.');
const rec = await tc.getRecord(zone, shortName, type);
const actual = rec && (rec.rData ? rec.rData.ipAddress : rec.ipAddress);
return {
name,
type,
expectedIp,
method: 'api-readback',
resolved: actual ? [actual] : [],
verified: String(actual || '').toLowerCase() === String(expectedIp).toLowerCase(),
};
}
/**
* Split-DNS: only when the ACTIVE provider exposes splitDnsApi
* (Tailscale). WireGuard devices get the Technitium hub IP in their
* generated client config instead (spec §8.3).
*/
async function configureSplitDns({ zone, dnsServerIp } = {}) {
if (!zone || !dnsServerIp) {
throw new ProviderError('configureSplitDns: zone and dnsServerIp are required', { code: 'bad_input' });
}
const unsupported = {
configured: false,
reason: 'splitDnsApi unsupported — set DNS in client config',
};
if (!resolveActiveProvider) return unsupported;
let provider;
try {
provider = resolveActiveProvider();
} catch (_err) {
return unsupported;
}
const caps = provider.capabilities ? provider.capabilities() : {};
if (!caps.splitDnsApi) return unsupported;
const out = await provider.configureDns({ zone, server: dnsServerIp });
return { configured: true, provider: provider.id ? provider.id() : undefined, ...out };
}
/**
* Wizard connectivity check (DC-152): never throws.
* @returns {Promise<{apiReachable:boolean, tokenValid:boolean, zoneExists:boolean, error?:string}>}
*/
async function checkSetup({ zone } = {}) {
const out = { apiReachable: false, tokenValid: false, zoneExists: false };
try {
const zones = await tc.listZones();
out.apiReachable = true;
out.tokenValid = true;
if (zone) {
out.zoneExists = zones.some((z) => String(z.name).toLowerCase() === String(zone).toLowerCase());
}
return out;
} catch (err) {
if (err.code === 'auth_failed') {
out.apiReachable = true; // server answered, token rejected
} else if (err.code === 'network_error') {
out.apiReachable = false;
} else {
out.apiReachable = true; // e.g. api_error / 5xx — the API is up
}
out.error = `${err.code}: ${err.message}`;
return out;
}
}
return {
client: tc,
syncRecords,
verifyResolution,
configureSplitDns,
checkSetup,
ensureZone: (name) => tc.ensureZone(name),
listZones: () => tc.listZones(),
};
}
module.exports = {
createTechnitiumClient,
createTechnitiumService,
resolveOverUdp,
buildDnsQuery,
parseDnsAnswer,
DEFAULT_CREDENTIAL_KEY,
};