Add DNS diagnostics across dataplane and client
This commit is contained in:
@@ -192,7 +192,15 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
||||
test('diagnostics settings validate and replace one canonical revision', () => {
|
||||
let state = {
|
||||
revision: 7,
|
||||
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
||||
diagnostics: {
|
||||
configured: false,
|
||||
customServices: [],
|
||||
hiddenServiceIds: [],
|
||||
customDnsResolvers: [{
|
||||
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||
}],
|
||||
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||
},
|
||||
};
|
||||
const useCase = createConnectivityDiagnosticsUseCase({
|
||||
state: {
|
||||
@@ -212,6 +220,10 @@ test('diagnostics settings validate and replace one canonical revision', () => {
|
||||
configured: true,
|
||||
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||
hiddenServiceIds: ['speedtest'],
|
||||
customDnsResolvers: [{
|
||||
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||
}],
|
||||
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||
});
|
||||
assert.equal(state.revision, 8);
|
||||
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
||||
@@ -245,6 +257,11 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
||||
calls.push(args);
|
||||
return { checkedAt: 'now', vpn: { server: null } };
|
||||
},
|
||||
dnsCatalog: async () => ({ resolvers: [{ id: 'google-dns' }], domains: [] }),
|
||||
runDns: async (...args) => {
|
||||
calls.push(['dns', ...args]);
|
||||
return { checkedAt: 'dns-now', results: [] };
|
||||
},
|
||||
updateSettings: (...args) => calls.push(['settings', ...args]),
|
||||
},
|
||||
readBody: async () => {
|
||||
@@ -276,6 +293,16 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
||||
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
||||
assert.equal(settingsResponse.payload.success, true);
|
||||
|
||||
const catalogResponse = routeResponse();
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/diagnostics/dns' }, catalogResponse), true);
|
||||
assert.deepEqual(catalogResponse.payload, { resolvers: [{ id: 'google-dns' }], domains: [] });
|
||||
|
||||
body = { domainId: 'youtube', resolverId: 'google-dns' };
|
||||
const dnsResponse = routeResponse();
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/diagnostics/dns' }, dnsResponse), true);
|
||||
assert.deepEqual(calls.at(-1), ['dns', 'youtube', 'google-dns']);
|
||||
assert.deepEqual(dnsResponse.payload, { checkedAt: 'dns-now', results: [] });
|
||||
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/diagnostics/connectivity'],
|
||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||
@@ -284,7 +311,7 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
||||
]) {
|
||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||
}
|
||||
assert.equal(bodyReads, 3);
|
||||
assert.equal(bodyReads, 4);
|
||||
});
|
||||
|
||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||
|
||||
@@ -34,6 +34,8 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||
'site:custom-test',
|
||||
);
|
||||
await client.getDnsDiagnosticsCatalog([{ id: 'dns' }], [{ id: 'domain' }]);
|
||||
await client.runDnsDiagnostics([{ id: 'dns' }], [{ id: 'domain' }], 'youtube', 'google-dns');
|
||||
await client.checkConfig({ outbounds: [] });
|
||||
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
||||
await client.readFailoverSelector();
|
||||
@@ -53,6 +55,8 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
'GET /device-policy /run/dataplane.sock',
|
||||
'PUT /device-policy /run/dataplane.sock',
|
||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||
'POST /diagnostics/dns/catalog /run/dataplane.sock',
|
||||
'POST /diagnostics/dns /run/dataplane.sock',
|
||||
'POST /config/check /run/dataplane.sock',
|
||||
'POST /failover/probe /run/dataplane.sock',
|
||||
'GET /failover/selector /run/dataplane.sock',
|
||||
@@ -68,10 +72,18 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
target: 'site:custom-test',
|
||||
});
|
||||
assert.equal(requests[8].timeoutMs, 25_000);
|
||||
assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
|
||||
assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||
assert.equal(requests[10].timeoutMs, 19_000);
|
||||
assert.deepEqual(requests[12].body, { role: 'reserve' });
|
||||
assert.deepEqual(requests[9].body, {
|
||||
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||
});
|
||||
assert.deepEqual(requests[10].body, {
|
||||
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||
domainId: 'youtube', resolverId: 'google-dns',
|
||||
});
|
||||
assert.equal(requests[10].timeoutMs, 40_000);
|
||||
assert.deepEqual(requests[11].body, { config: { outbounds: [] } });
|
||||
assert.deepEqual(requests[12].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||
assert.equal(requests[12].timeoutMs, 19_000);
|
||||
assert.deepEqual(requests[14].body, { role: 'reserve' });
|
||||
});
|
||||
|
||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildDnsQuery,
|
||||
createDnsDiagnosticsService,
|
||||
parseDnsResponse,
|
||||
} from '../../dist/server/services/dnsDiagnosticsService.js';
|
||||
|
||||
function wireResponse(query, { rcode = 0, truncated = false, address = null } = {}) {
|
||||
const header = Buffer.alloc(12);
|
||||
query.copy(header, 0, 0, 2);
|
||||
header.writeUInt16BE(0x8180 | rcode | (truncated ? 0x0200 : 0), 2);
|
||||
header.writeUInt16BE(1, 4);
|
||||
header.writeUInt16BE(address ? 1 : 0, 6);
|
||||
const question = query.subarray(12);
|
||||
if (!address) return Buffer.concat([header, question]);
|
||||
const type = query.readUInt16BE(query.length - 4);
|
||||
const data = type === 1
|
||||
? Buffer.from(address.split('.').map(Number))
|
||||
: Buffer.from(address.split(':').flatMap((part) => {
|
||||
const value = Number.parseInt(part || '0', 16);
|
||||
return [value >> 8, value & 0xff];
|
||||
}));
|
||||
const answer = Buffer.alloc(12);
|
||||
answer.writeUInt16BE(0xc00c, 0);
|
||||
answer.writeUInt16BE(type, 2);
|
||||
answer.writeUInt16BE(1, 4);
|
||||
answer.writeUInt32BE(60, 6);
|
||||
answer.writeUInt16BE(data.length, 10);
|
||||
return Buffer.concat([header, question, answer, data]);
|
||||
}
|
||||
|
||||
function addressFor(packet, path = 'direct') {
|
||||
return packet.readUInt16BE(packet.length - 4) === 1
|
||||
? path === 'direct' ? '8.8.8.8' : '9.9.9.9'
|
||||
: path === 'direct' ? '2001:db8:0:0:0:0:0:1' : '2001:db8:0:0:0:0:0:2';
|
||||
}
|
||||
|
||||
test('DNS wire codec parses compressed A/AAAA and rejects malformed packets', () => {
|
||||
const aQuery = buildDnsQuery('www.youtube.com', 'A', 17);
|
||||
const aaaaQuery = buildDnsQuery('www.youtube.com', 'AAAA', 18);
|
||||
assert.deepEqual(parseDnsResponse(wireResponse(aQuery, { address: '8.8.8.8' }), 17), {
|
||||
rcode: 'NOERROR', truncated: false, ipv4: ['8.8.8.8'], ipv6: [],
|
||||
});
|
||||
assert.deepEqual(parseDnsResponse(wireResponse(aaaaQuery, {
|
||||
address: '2001:db8:0:0:0:0:0:1',
|
||||
}), 18).ipv6, ['2001:db8:0:0:0:0:0:1']);
|
||||
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 3 }), 17).rcode, 'NXDOMAIN');
|
||||
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 2 }), 17).rcode, 'SERVFAIL');
|
||||
assert.throws(() => parseDnsResponse(Buffer.from([0, 1]), 1), /malformed/);
|
||||
assert.throws(() => parseDnsResponse(wireResponse(aQuery), 99), /unexpected/);
|
||||
});
|
||||
|
||||
test('DNS checker keeps Direct and VPN independent and never probes VPN when it is off', async () => {
|
||||
const calls = [];
|
||||
const classicExchange = async ({ path: route, resolver, packet }) => {
|
||||
calls.push([route, resolver.endpoint, packet.readUInt16BE(packet.length - 4)]);
|
||||
return {
|
||||
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||
transport: 'udp',
|
||||
latencyMs: route === 'direct' ? 10 : 20,
|
||||
};
|
||||
};
|
||||
const service = createDnsDiagnosticsService({
|
||||
proxyPort: 18080, getServers: () => [], classicExchange,
|
||||
now: () => '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
const running = await service.run({ vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns' });
|
||||
assert.deepEqual(calls.map(([route]) => route).sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||
assert.equal(running.results[0].comparison, 'different');
|
||||
assert.deepEqual(running.results[0].direct.ipv4, ['8.8.8.8']);
|
||||
assert.deepEqual(running.results[0].vpn.ipv6, ['2001:db8:0:0:0:0:0:2']);
|
||||
|
||||
calls.length = 0;
|
||||
const stopped = await service.run({ vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns' });
|
||||
assert.deepEqual(calls.map(([route]) => route), ['direct', 'direct']);
|
||||
assert.equal(stopped.results[0].vpn.status, 'vpn-off');
|
||||
});
|
||||
|
||||
test('DNS checker retries timeout once and uses TCP fallback on a truncated response', async () => {
|
||||
let attempts = 0;
|
||||
const timedOut = createDnsDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
getServers: () => [],
|
||||
classicExchange: async () => {
|
||||
attempts += 1;
|
||||
const error = new Error('timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const timeoutResult = await timedOut.run({
|
||||
vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns',
|
||||
});
|
||||
assert.equal(attempts, 4);
|
||||
assert.equal(timeoutResult.results[0].direct.status, 'timeout');
|
||||
|
||||
const fallbackCalls = [];
|
||||
const fallback = createDnsDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
getServers: () => [],
|
||||
classicExchange: async ({ packet }) => ({
|
||||
response: wireResponse(packet, { truncated: true }), transport: 'udp', latencyMs: 2,
|
||||
}),
|
||||
tcpExchange: async ({ path: route, packet }) => {
|
||||
fallbackCalls.push(route);
|
||||
return {
|
||||
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||
transport: 'tcp', latencyMs: 4,
|
||||
};
|
||||
},
|
||||
});
|
||||
const fallbackResult = await fallback.run({
|
||||
vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns',
|
||||
});
|
||||
assert.deepEqual(fallbackCalls.sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||
assert.equal(fallbackResult.results[0].direct.transport, 'tcp');
|
||||
assert.equal(fallbackResult.results[0].vpn.transport, 'tcp');
|
||||
});
|
||||
|
||||
test('custom DoH is pinned only to public bootstrap addresses', async () => {
|
||||
const resolver = {
|
||||
id: 'custom-dns-office', label: 'Office DoH', kind: 'doh', endpoint: 'https://dns.example/dns-query',
|
||||
};
|
||||
const calls = [];
|
||||
const service = createDnsDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
getServers: () => [],
|
||||
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
|
||||
dohExchange: async ({ path: route, resolver: pinned, packet }) => {
|
||||
calls.push([route, pinned.bootstrap]);
|
||||
return {
|
||||
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||
transport: 'doh', latencyMs: 7,
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await service.run({
|
||||
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
['direct', '93.184.216.34'], ['direct', '93.184.216.34'],
|
||||
['vpn', '93.184.216.34'], ['vpn', '93.184.216.34'],
|
||||
]);
|
||||
assert.equal(result.results[0].direct.transport, 'doh');
|
||||
|
||||
let requests = 0;
|
||||
const blocked = createDnsDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
getServers: () => [],
|
||||
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||
dohExchange: async () => { requests += 1; throw new Error('unexpected'); },
|
||||
});
|
||||
const blockedResult = await blocked.run({
|
||||
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||
});
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(blockedResult.results[0].direct.error, 'DoH endpoint is not public');
|
||||
});
|
||||
|
||||
test('full DNS run includes system and built-in rows with at most three resolver workers', async () => {
|
||||
const active = new Map();
|
||||
let maximumResolvers = 0;
|
||||
const exchange = async ({ path: route, resolver, packet }) => {
|
||||
active.set(resolver.id, (active.get(resolver.id) || 0) + 1);
|
||||
maximumResolvers = Math.max(maximumResolvers, active.size);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
const count = active.get(resolver.id) - 1;
|
||||
if (count) active.set(resolver.id, count);
|
||||
else active.delete(resolver.id);
|
||||
return {
|
||||
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||
transport: resolver.kind === 'doh' ? 'doh' : 'udp',
|
||||
latencyMs: 2,
|
||||
};
|
||||
};
|
||||
const service = createDnsDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
getServers: () => ['192.168.1.1'],
|
||||
classicExchange: exchange,
|
||||
dohExchange: exchange,
|
||||
});
|
||||
const catalog = await service.catalog();
|
||||
assert.equal(catalog.resolvers[0].system, true);
|
||||
assert.equal(catalog.resolvers.length, 7);
|
||||
assert.deepEqual(catalog.domains.map(({ hostname }) => hostname), ['www.youtube.com', 'chatgpt.com']);
|
||||
|
||||
const result = await service.run({ vpnAvailable: false, domainId: 'youtube' });
|
||||
assert.equal(result.results.length, 7);
|
||||
assert.ok(maximumResolvers <= 3);
|
||||
});
|
||||
|
||||
test('default transports enforce direct bypass, VPN proxy, binary DoH and no redirects', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '../../src/server/services/dnsDiagnosticsService.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /path === 'direct'[\s\S]*udpExchange\(address, port/);
|
||||
assert.match(source, /socksHandshake\(3,[\s\S]*socksHandshake\(1,/);
|
||||
assert.match(source, /'--noproxy', '\*'/);
|
||||
assert.match(source, /'--proxy', `http:\/\/127\.0\.0\.1:\$\{proxyPort\}`/);
|
||||
assert.match(source, /'--data-binary', '@-'/);
|
||||
assert.match(source, /'--resolve'/);
|
||||
assert.doesNotMatch(source, /--location/);
|
||||
});
|
||||
@@ -102,6 +102,8 @@ test('schema v5 migrates rules and diagnostics settings with an exact backup', (
|
||||
configured: false,
|
||||
customServices: [],
|
||||
hiddenServiceIds: [],
|
||||
customDnsResolvers: [],
|
||||
customDnsDomains: [],
|
||||
});
|
||||
assert.equal(migrated.routeRulesRevision, 7);
|
||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||
@@ -149,6 +151,35 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
});
|
||||
|
||||
test('schema v9 adds empty DNS settings atomically and preserves the exact backup', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
schemaVersion: 9,
|
||||
revision: 23,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
diagnostics: {
|
||||
configured: true,
|
||||
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/' }],
|
||||
hiddenServiceIds: ['youtube'],
|
||||
},
|
||||
};
|
||||
const bytes = JSON.stringify(legacy);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
|
||||
const store = createStateStore(filePath, {
|
||||
now: () => new Date('2026-09-01T12:00:00.000Z'),
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, 10);
|
||||
assert.equal(migrated.revision, 23);
|
||||
assert.deepEqual(migrated.diagnostics.customDnsResolvers, []);
|
||||
assert.deepEqual(migrated.diagnostics.customDnsDomains, []);
|
||||
assert.equal(store.migration.fromVersion, 9);
|
||||
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||
for (const [name, rule] of [
|
||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||
|
||||
Reference in New Issue
Block a user