diff --git a/dashcaddy-api/__tests__/network-technitium.test.js b/dashcaddy-api/__tests__/network-technitium.test.js new file mode 100644 index 0000000..0677cda --- /dev/null +++ b/dashcaddy-api/__tests__/network-technitium.test.js @@ -0,0 +1,489 @@ +/** + * Tests for src/network/technitium.js (DC-146). + * + * Hermetic: fetchImpl is a jest.fn() stub, credentialManager and the + * network registry are stub objects (same rigor as + * __tests__/network-provider-tailscale.test.js). No real Technitium, + * no real sockets. + */ + +'use strict'; + +const { + createTechnitiumClient, + createTechnitiumService, +} = require('../src/network/technitium'); +const { ProviderError } = require('../src/network/errors'); + +/* ------------------------------------------------------------------ * + * HTTP stub helpers + * ------------------------------------------------------------------ */ + +const _JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function jsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + statusText: 'stub', + json: async () => Promise.resolve(body), + }; +} + +function textResponse(status, text) { + return { + ok: status >= 200 && status < 300, + status, + statusText: text, + json: async () => Promise.reject(new Error('not json')), + }; +} + +/** fetch stub that routes by URL path; handlers appended as [matcher, fn]. */ +function makeFetch(routes = [], { defaultResponse } = {}) { + const calls = []; + const fn = async (url, init) => { + calls.push({ url: String(url), init }); + const u = new URL(String(url)); + for (const [match, handler] of routes) { + if (u.pathname.endsWith(match)) return handler(u, init, calls.length - 1); + } + if (defaultResponse) return defaultResponse(u, init); + return jsonResponse(404, { status: 'error', errorMessage: 'no route' }); + }; + fn.mock = {}; // jest.fn()-shape compatibility if inspected + fn.calls = calls; + return fn; +} + +function credMgr(token) { + const retrieve = async () => (token === undefined ? null : token); + return { retrieve: jest.fn(retrieve) }; +} + +const noopLogger = { info() {}, warn: jest.fn(), error: jest.fn(), debug() {} }; + +function makeClient(routes, { token = 'tok123', host = 'dns.local', ...rest } = {}) { + const fetchImpl = typeof routes === 'function' ? routes : makeFetch(routes); + const getToken = async () => token; + const client = createTechnitiumClient({ + host, + getToken, + fetchImpl, + logger: noopLogger, + ...rest, + }); + return { client, fetchImpl }; +} + +/* ------------------------------------------------------------------ * + * Client: zones + * ------------------------------------------------------------------ */ + +describe('technitium client: listZones + ensureZone idempotency', () => { + test('listZones normalizes the response array', async () => { + const { client } = makeClient([ + ['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })], + ]); + const zones = await client.listZones(); + expect(zones).toEqual([{ name: 'corp', type: 'Primary' }]); + }); + + test('ensureZone creates a Primary zone only when absent', async () => { + const zones = []; + const fetchImpl = makeFetch([ + ['/zones/list', () => jsonResponse(200, { status: 'ok', response: zones })], + ['/zones/create', () => { zones.push({ name: 'corp', type: 'Primary' }); return jsonResponse(200, { status: 'ok' }); }], + ]); + const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }); + + const first = await client.ensureZone('corp'); + expect(first).toEqual({ created: true, unchanged: false }); + expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1); + + const second = await client.ensureZone('corp'); // now present + expect(second).toEqual({ created: false, unchanged: true }); + expect(fetchImpl.calls.filter((c) => c.url.includes('/zones/create'))).toHaveLength(1); + }); +}); + +/* ------------------------------------------------------------------ * + * Client: upsertRecord three paths + * ------------------------------------------------------------------ */ + +describe('technitium client: upsertRecord', () => { + function recordApi(existing, handlers = {}) { + let rec = existing; + const routes = [ + ['/zones/records/get', (u) => { + if (handlers.get) return handlers.get(u); + if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' }); + return jsonResponse(200, { status: 'ok', response: rec }); + }], + ['/zones/records/delete', (u, init) => { + if (handlers.del) return handlers.del(u, init); + rec = null; + return jsonResponse(200, { status: 'ok' }); + }], + ['/zones/records/add', (u, init) => { + if (handlers.add) return handlers.add(u, init); + const body = JSON.parse(init.body); + rec = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } }; + return jsonResponse(200, { status: 'ok' }); + }], + ]; + return makeFetch(routes); + } + + test('absent record → created (single add, no delete)', async () => { + const fetchImpl = recordApi(null); + const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }); + const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } }); + expect(out).toEqual({ created: true }); + expect(fetchImpl.calls.filter((c) => c.url.includes('/add'))).toHaveLength(1); + expect(fetchImpl.calls.filter((c) => c.url.includes('/delete'))).toHaveLength(0); + }); + + test('present record with same rData → unchanged no-op (no add, no delete)', async () => { + const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } }); + const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }); + const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } }); + expect(out).toEqual({ unchanged: true }); + expect(fetchImpl.calls).toHaveLength(1); // only the get + }); + + test('present record with different rData → delete + add → updated', async () => { + const fetchImpl = recordApi({ name: 'app', type: 'A', rData: { ipAddress: '10.53.0.9' } }); + const client = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }); + const out = await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } }); + expect(out).toEqual({ updated: true }); + const add = fetchImpl.calls.find((c) => c.url.includes('/add')); + expect(JSON.parse(add.init.body)).toMatchObject({ zone: 'corp', name: 'app', type: 'A', ipAddress: '10.53.0.5' }); + expect(fetchImpl.calls.some((c) => c.url.includes('/delete'))).toBe(true); + }); + + test('auth token rides on every request (query param on GET, body on POST)', async () => { + const fetchImpl = recordApi(null); + const client = createTechnitiumClient({ host: 'h', getToken: async () => 'sekrit', fetchImpl }); + await client.upsertRecord({ zone: 'corp', name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } }); + const get = fetchImpl.calls[0]; + expect(new URL(get.url).searchParams.get('token')).toBe('sekrit'); + const add = fetchImpl.calls.find((c) => c.url.includes('/add')); + expect(JSON.parse(add.init.body).token).toBe('sekrit'); + }); +}); + +/* ------------------------------------------------------------------ * + * Client: error mapping + timeout + * ------------------------------------------------------------------ */ + +describe('technitium client: error mapping', () => { + test('HTTP 401/403 → ProviderError auth_failed', async () => { + const { client } = makeClient([ + ['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })], + ]); + await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed', name: 'ProviderError' }); + }); + + test('envelope status:"error" with "not found" → not_found (getRecord → null)', async () => { + const { client } = makeClient([ + ['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })], + ]); + await expect(client.getRecord('corp', 'app', 'A')).resolves.toBeNull(); + }); + + test('envelope token error → auth_failed', async () => { + const { client } = makeClient([ + ['/zones/list', () => jsonResponse(200, { status: 'error', errorMessage: 'Invalid token' })], + ]); + await expect(client.listZones()).rejects.toMatchObject({ code: 'auth_failed' }); + }); + + test('HTTP 500 → api_error; transport throw → network_error', async () => { + const { client: c1 } = makeClient([ + ['/zones/list', () => textResponse(500, 'boom')], + ]); + await expect(c1.listZones()).rejects.toMatchObject({ code: 'api_error' }); + + const fetchThrows = jest.fn(async () => { const e = new Error('ECONNREFUSED'); e.name = 'FetchError'; throw e; }); + const c2 = createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: fetchThrows }); + await expect(c2.listZones()).rejects.toMatchObject({ code: 'network_error' }); + }); + + test('timeout: fetch that never settles → network_error within the budget', async () => { + const fetchImpl = jest.fn(() => new Promise(() => {})); // hangs forever + const client = createTechnitiumClient({ + host: 'h', + getToken: async () => 't', + fetchImpl, + timeoutMs: 50, + }); + const t0 = Date.now(); + await expect(client.listZones()).rejects.toMatchObject({ code: 'network_error' }); + expect(Date.now() - t0).toBeLessThan(2000); + }); +}); + +/* ------------------------------------------------------------------ * + * Service: syncRecords + * ------------------------------------------------------------------ */ + +describe('technitium service: syncRecords', () => { + function serviceWith(existingByFqdn) { + const state = { ...existingByFqdn }; + const fetchImpl = makeFetch([ + ['/zones/records/get', (u) => { + const name = u.searchParams.get('name'); + const rec = state[name]; + if (!rec) return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' }); + return jsonResponse(200, { status: 'ok', response: rec }); + }], + ['/zones/records/delete', (u, init) => { + const body = JSON.parse(init.body); + delete state[body.name]; + return jsonResponse(200, { status: 'ok' }); + }], + ['/zones/records/add', (u, init) => { + const body = JSON.parse(init.body); + state[body.name] = { name: body.name, type: body.type, rData: { ipAddress: body.ipAddress } }; + return jsonResponse(200, { status: 'ok' }); + }], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + return { svc, state }; + } + + test('resources + devices → name.zone records; three upsert paths in one batch', async () => { + const { svc } = serviceWith({ 'app': { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } }); + const out = await svc.syncRecords({ + zone: 'corp', + providerMode: 'wireguard', + resources: [ + { name: 'app', ip: '10.53.0.5' }, // unchanged + { name: 'git', ip: '10.53.1.7' }, // created + ], + devices: [ + { slug: 'laptop', ip: '10.53.2.9' }, // created + ], + }); + expect(out.zone).toBe('corp'); + expect(out.count).toBe(3); + expect(Object.fromEntries(out.results.map((r) => [r.name, r.action]))).toEqual({ + 'app.corp': 'unchanged', + 'git.corp': 'created', + 'laptop.corp': 'created', + }); + }); + + test('tailscale mode prefers tailnetIp; wireguard mode prefers ip', async () => { + const seen = {}; + const fetchImpl = makeFetch([ + ['/zones/records/get', () => jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' })], + ['/zones/records/add', (u, init) => { + const b = JSON.parse(init.body); + seen[b.name] = b.ipAddress; + return jsonResponse(200, { status: 'ok' }); + }], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + const entry = { name: 'nas', tailnetIp: '100.91.55.51', ip: '10.53.0.9' }; + await svc.syncRecords({ zone: 'corp', providerMode: 'tailscale', resources: [entry] }); + expect(seen.nas).toBe('100.91.55.51'); + await svc.syncRecords({ zone: 'corp', providerMode: 'wireguard', resources: [entry] }); + expect(seen.nas).toBe('10.53.0.9'); + }); + + test('per-record failure isolation: one bad record does not block the rest', async () => { + let n = 0; + const fetchImpl = makeFetch([ + ['/zones/records/get', () => { + n += 1; + if (n === 1) return Promise.reject(new Error('ECONNRESET')); // first record fails at transport + return jsonResponse(200, { status: 'error', errorMessage: 'Record not found.' }); + }], + ['/zones/records/add', () => jsonResponse(200, { status: 'ok' })], + ]); + const logger = { ...noopLogger, warn: jest.fn() }; + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger, + }); + const out = await svc.syncRecords({ + zone: 'corp', + resources: [{ name: 'bad', ip: '10.53.0.1' }, { name: 'ok', ip: '10.53.0.2' }], + }); + expect(out.count).toBe(2); + expect(out.results[0]).toMatchObject({ name: 'bad.corp', action: 'failed' }); + expect(typeof out.results[0].error).toBe('string'); + expect(out.results[0].error).toContain('network_error'); + expect(out.results[1]).toEqual({ name: 'ok.corp', action: 'created' }); + expect(logger.warn).toHaveBeenCalled(); + }); + + test('missing zone → bad_input ProviderError', async () => { + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }), + logger: noopLogger, + }); + await expect(svc.syncRecords({ resources: [] })).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +/* ------------------------------------------------------------------ * + * Service: verifyResolution + * ------------------------------------------------------------------ */ + +describe('technitium service: verifyResolution', () => { + test('falls back to API read-back when no dnsServer (method noted)', async () => { + const fetchImpl = makeFetch([ + ['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { name: 'app', type: 'A', rData: { ipAddress: '10.53.0.5' } } })], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + const out = await svc.verifyResolution({ name: 'app.corp', type: 'A', expectedIp: '10.53.0.5' }); + expect(out.method).toBe('api-readback'); + expect(out.verified).toBe(true); + }); + + test('read-back mismatch → verified:false', async () => { + const fetchImpl = makeFetch([ + ['/zones/records/get', () => jsonResponse(200, { status: 'ok', response: { rData: { ipAddress: '10.53.0.99' } } })], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + const out = await svc.verifyResolution({ name: 'app.corp', expectedIp: '10.53.0.5' }); + expect(out.verified).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ * + * Service: configureSplitDns capability gating + * ------------------------------------------------------------------ */ + +describe('technitium service: configureSplitDns capability gate', () => { + function providerStub({ splitDnsApi, id = 'stub' }) { + return { + id: () => id, + capabilities: () => ({ invites: true, splitDnsApi, perPeerFirewall: false, selfHosted: true }), + configureDns: jest.fn(async ({ zone, server }) => ({ delegated: true, zone, server })), + }; + } + + test('tailscale (splitDnsApi:true) → delegates to provider.configureDns', async () => { + const ts = providerStub({ splitDnsApi: true, id: 'tailscale' }); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }), + getActiveProvider: () => ts, + logger: noopLogger, + }); + const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' }); + expect(out).toMatchObject({ configured: true, provider: 'tailscale' }); + expect(ts.configureDns).toHaveBeenCalledWith({ zone: 'corp', server: '10.53.0.1' }); + }); + + test('wireguard (splitDnsApi:false) → {configured:false, reason} per spec', async () => { + const wg = providerStub({ splitDnsApi: false, id: 'wireguard' }); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }), + getActiveProvider: () => wg, + logger: noopLogger, + }); + const out = await svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' }); + expect(out).toEqual({ + configured: false, + reason: 'splitDnsApi unsupported — set DNS in client config', + }); + }); + + test('no active provider → same unsupported result, no throw', async () => { + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl: makeFetch([]) }), + getActiveProvider: () => { throw new ProviderError('none', { code: 'provider_not_active' }); }, + logger: noopLogger, + }); + await expect(svc.configureSplitDns({ zone: 'corp', dnsServerIp: '10.53.0.1' })).resolves.toMatchObject({ + configured: false, + }); + }); +}); + +/* ------------------------------------------------------------------ * + * Service: checkSetup (wizard, DC-152) + * ------------------------------------------------------------------ */ + +describe('technitium service: checkSetup red/green', () => { + test('green: reachable + valid token + zone present', async () => { + const fetchImpl = makeFetch([ + ['/zones/list', () => jsonResponse(200, { status: 'ok', response: [{ name: 'corp', type: 'Primary' }] })], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + await expect(svc.checkSetup({ zone: 'corp' })).resolves.toEqual({ + apiReachable: true, + tokenValid: true, + zoneExists: true, + }); + }); + + test('red: unreachable → apiReachable:false, tokenValid:false', async () => { + const fetchImpl = jest.fn(async () => { const e = new Error('connect ECONNREFUSED'); e.name = 'FetchError'; throw e; }); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 't', fetchImpl }), + logger: noopLogger, + }); + const out = await svc.checkSetup({ zone: 'corp' }); + expect(out).toMatchObject({ apiReachable: false, tokenValid: false, zoneExists: false }); + expect(out.error).toContain('network_error'); + }); + + test('yellow: reachable but bad token → apiReachable:true, tokenValid:false', async () => { + const fetchImpl = makeFetch([ + ['/zones/list', () => jsonResponse(401, { status: 'error', errorMessage: 'Unauthorized' })], + ]); + const svc = createTechnitiumService({ + client: createTechnitiumClient({ host: 'h', getToken: async () => 'bad', fetchImpl }), + logger: noopLogger, + }); + const out = await svc.checkSetup({ zone: 'corp' }); + expect(out).toMatchObject({ apiReachable: true, tokenValid: false, zoneExists: false }); + }); +}); + +/* ------------------------------------------------------------------ * + * Service: credential-manager wiring + * ------------------------------------------------------------------ */ + +describe('technitium service: credential wiring', () => { + test('token resolved from credentialManager.retrieve("technitium"); {token} object supported', async () => { + const cm = credMgr({ token: 'obj-tok' }); + const fetchImpl = makeFetch([ + ['/zones/list', () => jsonResponse(200, { status: 'ok', response: [] })], + ]); + const svc = createTechnitiumService({ + credentialManager: cm, + config: { technitium: { host: 'dns.internal' } }, + fetchImpl, + logger: noopLogger, + }); + await svc.listZones(); + expect(cm.retrieve).toHaveBeenCalledWith('technitium'); + expect(new URL(fetchImpl.calls[0].url).searchParams.get('token')).toBe('obj-tok'); + }); + + test('client construction requires host or baseUrl (never hardcoded)', () => { + expect(() => createTechnitiumClient({ getToken: async () => 't', fetchImpl: makeFetch([]) })) + .toThrow(ProviderError); + }); +}); diff --git a/dashcaddy-api/src/network/technitium.js b/dashcaddy-api/src/network/technitium.js new file mode 100644 index 0000000..03778af --- /dev/null +++ b/dashcaddy-api/src/network/technitium.js @@ -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://: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, +};