DC-146: Technitium zone/record sync (idempotent upserts) + split-DNS gate + setup check + tests
This commit is contained in:
2 files changed
+1064
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user