diff --git a/src/server/dataplane.ts b/src/server/dataplane.ts index 774e34d..4403b4e 100644 --- a/src/server/dataplane.ts +++ b/src/server/dataplane.ts @@ -11,6 +11,7 @@ import { readNeighborSnapshot } from './adapters/neighbors.js'; import { createDeviceTrafficService } from './services/deviceTrafficService.js'; import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; +import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js'; import { createDomainTrafficService, readSingboxConnections, @@ -57,6 +58,9 @@ const devicePolicy = createDevicePolicyService({ const connectivityDiagnostics = createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort, }); +const dnsDiagnostics = createDnsDiagnosticsService({ + proxyPort: settings.diagnosticsProxyPort, +}); const failoverDiagnostics = { primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }), reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }), @@ -292,6 +296,25 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons target, })); } + if (req.method === 'POST' && req.url === '/diagnostics/dns/catalog') { + const { customResolvers = [], customDomains = [] } = record(await readJson(req)); + return sendJson(res, 200, await dnsDiagnostics.catalog( + Array.isArray(customResolvers) ? customResolvers : [], + Array.isArray(customDomains) ? customDomains : [], + )); + } + if (req.method === 'POST' && req.url === '/diagnostics/dns') { + const { + customResolvers = [], customDomains = [], domainId, resolverId = null, + } = record(await readJson(req)); + return sendJson(res, 200, await dnsDiagnostics.run({ + vpnAvailable: runtime.running, + customResolvers: Array.isArray(customResolvers) ? customResolvers : [], + customDomains: Array.isArray(customDomains) ? customDomains : [], + domainId, + resolverId, + })); + } if (req.method === 'POST' && req.url === '/failover/probe') { const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req)); if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role'); diff --git a/src/server/dataplaneClient.ts b/src/server/dataplaneClient.ts index 3b1f876..f238a2e 100644 --- a/src/server/dataplaneClient.ts +++ b/src/server/dataplaneClient.ts @@ -83,6 +83,23 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq throw new HarborError('DIAGNOSTICS_FAILED', { cause }); } }, + getDnsDiagnosticsCatalog: (customResolvers: unknown = [], customDomains: unknown = []) => ( + send(socketPath, '/diagnostics/dns/catalog', 'POST', { customResolvers, customDomains }) + ), + runDnsDiagnostics: async ( + customResolvers: unknown = [], + customDomains: unknown = [], + domainId: unknown, + resolverId: unknown = null, + ) => { + try { + return await send(socketPath, '/diagnostics/dns', 'POST', { + customResolvers, customDomains, domainId, resolverId, + }, 40_000); + } catch (cause) { + throw new HarborError('DIAGNOSTICS_FAILED', { cause }); + } + }, checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000), runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => ( send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000) diff --git a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts index 6925680..2d9294e 100644 --- a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts +++ b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts @@ -27,6 +27,13 @@ interface ConnectivityDiagnosticsDependencies { update(mutator: (state: StoredState) => Record): StoredState; }; runDiagnostics(services: unknown, target: unknown): Promise; + dnsCatalog(customResolvers: unknown, customDomains: unknown): Promise; + runDnsDiagnostics( + customResolvers: unknown, + customDomains: unknown, + domainId: unknown, + resolverId: unknown, + ): Promise; } function diagnosticsResult(value: unknown): DiagnosticsResult { @@ -66,21 +73,38 @@ export function createConnectivityDiagnosticsUseCase( }, }; }, + async dnsCatalog() { + const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics); + return dependencies.dnsCatalog(settings.customDnsResolvers, settings.customDnsDomains); + }, + async runDns(domainId: unknown, resolverId: unknown) { + const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics); + return dependencies.runDnsDiagnostics( + settings.customDnsResolvers, + settings.customDnsDomains, + domainId, + resolverId, + ); + }, updateSettings(settings: unknown, expectedRevision: unknown) { if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) { throw new HarborError('REQUEST_INVALID'); } + const current = dependencies.state.read(); + if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); let diagnostics: DiagnosticSettings; try { const requested = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings as Record : {}; - diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true }); + diagnostics = normalizeDiagnosticSettings({ + ...normalizeDiagnosticSettings(current.diagnostics), + ...requested, + configured: true, + }, { strict: true }); } catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); } - const current = dependencies.state.read(); - if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); if (isDeepStrictEqual(current.diagnostics, diagnostics)) return; dependencies.state.update((state) => ({ ...state, diagnostics })); }, diff --git a/src/server/http/routes/connectivityDiagnosticsRoute.ts b/src/server/http/routes/connectivityDiagnosticsRoute.ts index 42d38a6..91e457a 100644 --- a/src/server/http/routes/connectivityDiagnosticsRoute.ts +++ b/src/server/http/routes/connectivityDiagnosticsRoute.ts @@ -4,7 +4,7 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/ import { sendJson } from '../response.js'; interface ConnectivityDiagnosticsRouteDependencies { - diagnostics: Pick; + diagnostics: Pick; readBody(req: IncomingMessage): Promise>; sendState(res: ServerResponse): Promise; } @@ -14,6 +14,15 @@ export function createConnectivityDiagnosticsRoute( ) { return { async handle(req: IncomingMessage, res: ServerResponse) { + if (req.url === '/api/diagnostics/dns' && req.method === 'GET') { + sendJson(res, 200, await dependencies.diagnostics.dnsCatalog()); + return true; + } + if (req.url === '/api/diagnostics/dns' && req.method === 'POST') { + const { domainId, resolverId = null } = await dependencies.readBody(req); + sendJson(res, 200, await dependencies.diagnostics.runDns(domainId, resolverId)); + return true; + } if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') { const { target = null } = await dependencies.readBody(req); const result = await dependencies.diagnostics.run(target); diff --git a/src/server/index.ts b/src/server/index.ts index 4651259..6ac40f0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -54,6 +54,7 @@ import { } from './services/deviceInventoryService.js'; import { buildVersionInfo } from './version.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; +import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js'; import { createStateService } from './features/state/stateService.js'; import { createStateRoute } from './http/routes/stateRoute.js'; import { sendError } from './http/response.js'; @@ -312,6 +313,9 @@ const deviceInventory = settings.appMode === 'gateway' const localConnectivityDiagnostics = !remoteDataplane ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) : null; +const localDnsDiagnostics = !remoteDataplane + ? createDnsDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) + : null; const localFailoverDiagnostics = !remoteDataplane ? { primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }), reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }), @@ -503,6 +507,23 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ services, target, }), + dnsCatalog: (customResolvers, customDomains) => remoteDataplane + ? requireRemoteRuntime().getDnsDiagnosticsCatalog(customResolvers, customDomains) + : localDnsDiagnostics!.catalog( + Array.isArray(customResolvers) ? customResolvers : [], + Array.isArray(customDomains) ? customDomains : [], + ), + runDnsDiagnostics: async (customResolvers, customDomains, domainId, resolverId) => remoteDataplane + ? requireRemoteRuntime().runDnsDiagnostics( + customResolvers, customDomains, domainId, resolverId, + ) + : localDnsDiagnostics!.run({ + vpnAvailable: Boolean((await singboxRuntime.refresh()).running), + customResolvers: Array.isArray(customResolvers) ? customResolvers : [], + customDomains: Array.isArray(customDomains) ? customDomains : [], + domainId, + resolverId, + }), }); const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({ diagnostics: connectivityDiagnostics, diff --git a/src/server/services/dnsDiagnosticsService.ts b/src/server/services/dnsDiagnosticsService.ts new file mode 100644 index 0000000..99b8216 --- /dev/null +++ b/src/server/services/dnsDiagnosticsService.ts @@ -0,0 +1,591 @@ +import crypto from 'node:crypto'; +import dgram from 'node:dgram'; +import { getServers as dnsGetServers } from 'node:dns'; +import { lookup as dnsLookup } from 'node:dns/promises'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; + +import { + DNS_DIAGNOSTIC_DOMAINS, + DNS_DIAGNOSTIC_RESOLVERS, + type CustomDnsDomain, + type CustomDnsResolver, + type DnsDomainDefinition, + type DnsResolverDefinition, +} from '../../shared/connectivityDiagnostics.js'; + +type PathKind = 'direct' | 'vpn'; +type RecordKind = 'A' | 'AAAA'; + +interface ParsedDnsResponse { + rcode: string; + truncated: boolean; + ipv4: string[]; + ipv6: string[]; +} + +interface WireExchange { + response: Buffer; + transport: 'udp' | 'tcp' | 'doh'; + latencyMs: number; +} + +interface QueryPathResult { + status: string; + rcode: string | null; + ipv4: string[]; + ipv6: string[]; + latencyMs: number | null; + transport: string | null; + error: string | null; +} + +interface ExchangeRequest { + path: PathKind; + resolver: DnsResolverDefinition; + packet: Buffer; + proxyPort: number; + timeoutMs: number; +} + +type ClassicExchange = (request: ExchangeRequest) => Promise; +type DohExchange = (request: ExchangeRequest) => Promise; +type TcpExchange = (request: ExchangeRequest) => Promise; + +const RCODE = ['NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED']; +const MAX_DNS_PACKET_BYTES = 65_535; +const CLASSIC_TIMEOUT_MS = 2_500; +const DOH_TIMEOUT_MS = 5_000; +const MAX_PARALLEL_RESOLVERS = 3; + +const BLOCKED_IPV4 = new net.BlockList(); +for (const [address, prefix] of [ + ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], + ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], + ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], + ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], + ['224.0.0.0', 4], ['240.0.0.0', 4], +] as Array<[string, number]>) BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4'); +const BLOCKED_IPV6 = new net.BlockList(); +for (const [address, prefix] of [ + ['::', 128], ['::1', 128], ['64:ff9b:1::', 48], ['100::', 64], + ['2001:db8::', 32], ['3fff::', 20], ['5f00::', 16], + ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], +] as Array<[string, number]>) BLOCKED_IPV6.addSubnet(address, prefix, 'ipv6'); + +function isPublicAddress(address: string) { + const family = net.isIP(address); + if (family === 4) return !BLOCKED_IPV4.check(address, 'ipv4'); + if (family === 6) return !BLOCKED_IPV6.check(address, 'ipv6'); + return false; +} + +function encodeName(hostname: string) { + const labels = hostname.split('.'); + const chunks = labels.map((label) => { + const value = Buffer.from(label, 'ascii'); + if (!value.length || value.length > 63) throw new Error('invalid DNS hostname'); + return Buffer.concat([Buffer.from([value.length]), value]); + }); + return Buffer.concat([...chunks, Buffer.from([0])]); +} + +export function buildDnsQuery(hostname: string, type: RecordKind, id: number) { + const header = Buffer.alloc(12); + header.writeUInt16BE(id, 0); + header.writeUInt16BE(0x0100, 2); + header.writeUInt16BE(1, 4); + const question = Buffer.alloc(4); + question.writeUInt16BE(type === 'A' ? 1 : 28, 0); + question.writeUInt16BE(1, 2); + return Buffer.concat([header, encodeName(hostname), question]); +} + +function readName(packet: Buffer, start: number) { + let offset = start; + let next = start; + let jumped = false; + const labels: string[] = []; + const visited = new Set(); + for (let depth = 0; depth < 64; depth += 1) { + if (offset >= packet.length) throw new Error('malformed DNS name'); + const length = packet[offset]; + if ((length & 0xc0) === 0xc0) { + if (offset + 1 >= packet.length) throw new Error('malformed DNS pointer'); + const pointer = ((length & 0x3f) << 8) | packet[offset + 1]; + if (visited.has(pointer)) throw new Error('recursive DNS pointer'); + visited.add(pointer); + if (!jumped) next = offset + 2; + jumped = true; + offset = pointer; + continue; + } + if (length & 0xc0) throw new Error('unsupported DNS label'); + offset += 1; + if (length === 0) { + if (!jumped) next = offset; + return { name: labels.join('.'), next }; + } + if (offset + length > packet.length) throw new Error('malformed DNS label'); + labels.push(packet.subarray(offset, offset + length).toString('ascii')); + offset += length; + if (!jumped) next = offset; + } + throw new Error('DNS name is too deep'); +} + +function ipv6Address(value: Buffer) { + const groups = []; + for (let offset = 0; offset < 16; offset += 2) groups.push(value.readUInt16BE(offset).toString(16)); + return groups.join(':'); +} + +export function parseDnsResponse(packet: Buffer, expectedId: number): ParsedDnsResponse { + if (packet.length < 12 || packet.length > MAX_DNS_PACKET_BYTES) throw new Error('malformed DNS response'); + if (packet.readUInt16BE(0) !== expectedId || !(packet.readUInt16BE(2) & 0x8000)) { + throw new Error('unexpected DNS response'); + } + const flags = packet.readUInt16BE(2); + const questionCount = packet.readUInt16BE(4); + const answerCount = packet.readUInt16BE(6); + let offset = 12; + for (let index = 0; index < questionCount; index += 1) { + offset = readName(packet, offset).next; + if (offset + 4 > packet.length) throw new Error('malformed DNS question'); + offset += 4; + } + const ipv4: string[] = []; + const ipv6: string[] = []; + for (let index = 0; index < answerCount; index += 1) { + offset = readName(packet, offset).next; + if (offset + 10 > packet.length) throw new Error('malformed DNS answer'); + const type = packet.readUInt16BE(offset); + const dnsClass = packet.readUInt16BE(offset + 2); + const length = packet.readUInt16BE(offset + 8); + offset += 10; + if (offset + length > packet.length) throw new Error('malformed DNS data'); + if (dnsClass === 1 && type === 1 && length === 4) ipv4.push([...packet.subarray(offset, offset + 4)].join('.')); + if (dnsClass === 1 && type === 28 && length === 16) ipv6.push(ipv6Address(packet.subarray(offset, offset + 16))); + offset += length; + } + const code = flags & 0x0f; + return { + rcode: RCODE[code] || `RCODE_${code}`, + truncated: Boolean(flags & 0x0200), + ipv4: [...new Set(ipv4)], + ipv6: [...new Set(ipv6)], + }; +} + +function resolverEndpoint(endpoint: string) { + if (net.isIP(endpoint)) return { address: endpoint, port: 53 }; + const ipv6 = /^\[([^\]]+)\]:(\d+)$/.exec(endpoint); + if (ipv6 && net.isIP(ipv6[1]) === 6) return { address: ipv6[1], port: Number(ipv6[2]) }; + const ipv4 = /^([^:]+):(\d+)$/.exec(endpoint); + if (ipv4 && net.isIP(ipv4[1]) === 4) return { address: ipv4[1], port: Number(ipv4[2]) }; + throw new Error('invalid DNS resolver'); +} + +function timeoutError() { + const error = new Error('timeout'); + Object.assign(error, { code: 'ETIMEDOUT' }); + return error; +} + +async function udpExchange(address: string, port: number, packet: Buffer, timeoutMs: number) { + const socket = dgram.createSocket(net.isIP(address) === 6 ? 'udp6' : 'udp4'); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.close(); + reject(timeoutError()); + }, timeoutMs); + socket.once('error', (error) => { + clearTimeout(timer); + socket.close(); + reject(error); + }); + socket.once('message', (message) => { + clearTimeout(timer); + socket.close(); + resolve(message); + }); + socket.send(packet, port, address, (error) => { + if (!error) return; + clearTimeout(timer); + socket.close(); + reject(error); + }); + }); +} + +async function readExact(socket: net.Socket, length: number) { + const chunks: Buffer[] = []; + let total = 0; + while (total < length) { + const chunk = socket.read(length - total) as Buffer | null; + if (chunk) { + chunks.push(chunk); + total += chunk.length; + continue; + } + await Promise.race([ + once(socket, 'readable'), + once(socket, 'error').then(([error]) => Promise.reject(error)), + once(socket, 'close').then(() => Promise.reject(new Error('connection closed'))), + ]); + } + return Buffer.concat(chunks, total); +} + +async function connectSocket(host: string, port: number, timeoutMs: number) { + const socket = net.connect({ host, port }); + socket.setTimeout(timeoutMs, () => socket.destroy(timeoutError())); + await Promise.race([ + once(socket, 'connect'), + once(socket, 'error').then(([error]) => Promise.reject(error)), + ]); + return socket; +} + +function socksAddress(address: string, port: number) { + const family = net.isIP(address); + const portBytes = Buffer.alloc(2); + portBytes.writeUInt16BE(port, 0); + if (family === 4) return Buffer.concat([Buffer.from([1, ...address.split('.').map(Number)]), portBytes]); + if (family === 6) { + const bytes = Buffer.alloc(16); + const [head = '', tail = ''] = address.split('::'); + const headGroups = head ? head.split(':') : []; + const tailGroups = tail ? tail.split(':') : []; + const groups = address.includes('::') + ? [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill('0'), ...tailGroups] + : headGroups; + if (groups.length !== 8) throw new Error('invalid IPv6 resolver'); + groups.forEach((group, index) => bytes.writeUInt16BE(Number.parseInt(group || '0', 16), index * 2)); + return Buffer.concat([Buffer.from([4]), bytes, portBytes]); + } + throw new Error('invalid SOCKS destination'); +} + +async function socksHandshake(command: 1 | 3, address: string, port: number, proxyPort: number, timeoutMs: number) { + const socket = await connectSocket('127.0.0.1', proxyPort, timeoutMs); + socket.write(Buffer.from([5, 1, 0])); + const greeting = await readExact(socket, 2); + if (greeting[0] !== 5 || greeting[1] !== 0) throw new Error('SOCKS authentication failed'); + socket.write(Buffer.concat([Buffer.from([5, command, 0]), socksAddress(address, port)])); + const header = await readExact(socket, 4); + if (header[0] !== 5 || header[1] !== 0) throw new Error('SOCKS connection failed'); + const addressLength = header[3] === 1 ? 4 : header[3] === 4 ? 16 : header[3] === 3 ? (await readExact(socket, 1))[0] : 0; + if (!addressLength) throw new Error('invalid SOCKS response'); + const bound = await readExact(socket, addressLength + 2); + const boundAddress = header[3] === 1 + ? [...bound.subarray(0, 4)].join('.') + : header[3] === 4 + ? ipv6Address(bound.subarray(0, 16)) + : bound.subarray(0, addressLength).toString('ascii'); + return { + socket, + boundAddress: ['0.0.0.0', '::'].includes(boundAddress) ? '127.0.0.1' : boundAddress, + boundPort: bound.readUInt16BE(addressLength), + }; +} + +async function tcpDns(socket: net.Socket, packet: Buffer) { + const length = Buffer.alloc(2); + length.writeUInt16BE(packet.length, 0); + socket.write(Buffer.concat([length, packet])); + const size = (await readExact(socket, 2)).readUInt16BE(0); + if (!size || size > MAX_DNS_PACKET_BYTES) throw new Error('invalid DNS TCP response'); + return readExact(socket, size); +} + +async function defaultClassicExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise { + const { address, port } = resolverEndpoint(resolver.endpoint); + const startedAt = Date.now(); + if (path === 'direct') { + const response = await udpExchange(address, port, packet, timeoutMs); + return { response, transport: 'udp', latencyMs: Date.now() - startedAt }; + } + const control = await socksHandshake(3, '0.0.0.0', 0, proxyPort, timeoutMs); + try { + const target = socksAddress(address, port); + const request = Buffer.concat([Buffer.from([0, 0, 0]), target, packet]); + const response = await udpExchange(control.boundAddress, control.boundPort, request, timeoutMs); + const headerLength = response[3] === 1 ? 10 : response[3] === 4 ? 22 : response[3] === 3 ? 7 + response[4] : 0; + if (!headerLength || response[2] !== 0 || response.length <= headerLength) throw new Error('invalid SOCKS UDP response'); + return { response: response.subarray(headerLength), transport: 'udp', latencyMs: Date.now() - startedAt }; + } finally { + control.socket.destroy(); + } +} + +async function tcpFallback({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest) { + const { address, port } = resolverEndpoint(resolver.endpoint); + const startedAt = Date.now(); + const socket = path === 'direct' + ? await connectSocket(address, port, timeoutMs) + : (await socksHandshake(1, address, port, proxyPort, timeoutMs)).socket; + try { + return { response: await tcpDns(socket, packet), transport: 'tcp' as const, latencyMs: Date.now() - startedAt }; + } finally { + socket.destroy(); + } +} + +async function defaultDohExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise { + const endpoint = new URL(resolver.endpoint); + const marker = Buffer.from('\n__HARBOR_DOH_META__'); + const args = [ + '--silent', '--show-error', '--proto', '=https', '--connect-timeout', '3', + '--max-time', String(timeoutMs / 1_000), '--request', 'POST', + '--header', 'content-type: application/dns-message', '--header', 'accept: application/dns-message', + '--data-binary', '@-', '--output', '-', '--write-out', `${marker.toString()}%{http_code}:%{time_total}`, + ...(path === 'vpn' ? ['--proxy', `http://127.0.0.1:${proxyPort}`] : ['--noproxy', '*']), + ...(resolver.bootstrap ? ['--resolve', `${endpoint.hostname}:443:${resolver.bootstrap}`] : []), + resolver.endpoint, + ]; + const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdin.end(packet); + const [code] = await once(child, 'close') as [number | null]; + const output = Buffer.concat(stdout); + const markerIndex = output.lastIndexOf(marker); + if (code !== 0 || markerIndex < 0) throw new Error(Buffer.concat(stderr).toString('utf8').trim() || 'DoH request failed'); + const [statusText, secondsText] = output.subarray(markerIndex + marker.length).toString('ascii').split(':'); + const status = Number(statusText); + if (status < 200 || status >= 300) throw new Error(`DoH HTTP ${status}`); + const response = output.subarray(0, markerIndex); + if (!response.length || response.length > MAX_DNS_PACKET_BYTES) throw new Error('invalid DoH response'); + return { response, transport: 'doh', latencyMs: Math.round(Number(secondsText) * 1_000) }; +} + +function availableResolvers(customResolvers: CustomDnsResolver[], getServers: () => string[]) { + const system = getServers().map((endpoint) => ({ + id: `system-${crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 12)}`, + label: 'Системный DNS', + kind: 'dns' as const, + endpoint, + system: true, + })); + const custom: DnsResolverDefinition[] = customResolvers.map((resolver) => ({ ...resolver, custom: true })); + return [...system, ...DNS_DIAGNOSTIC_RESOLVERS, ...custom]; +} + +async function prepareResolver(resolver: DnsResolverDefinition, lookup: typeof dnsLookup) { + if (resolver.kind !== 'doh' || resolver.bootstrap) return resolver; + const endpoint = new URL(resolver.endpoint); + const addresses = await lookup(endpoint.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) { + throw new Error('DoH endpoint is not public'); + } + return { + ...resolver, + bootstrap: addresses.find(({ family }) => family === 4)?.address || addresses[0].address, + }; +} + +function domains(customDomains: CustomDnsDomain[]): DnsDomainDefinition[] { + return [...DNS_DIAGNOSTIC_DOMAINS, ...customDomains.map((domain) => ({ ...domain, custom: true }))]; +} + +function errorCode(error: unknown) { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code === 'ETIMEDOUT' || (error instanceof Error && error.message === 'timeout')) return 'timeout'; + const message = error instanceof Error ? error.message : ''; + if ( + message === 'DoH endpoint is not public' + || message.startsWith('malformed DNS') + || message.startsWith('unexpected DNS') + || /^DoH HTTP \d{3}$/.test(message) + ) return message; + return 'DNS request failed'; +} + +async function queryType( + hostname: string, + type: RecordKind, + path: PathKind, + resolver: DnsResolverDefinition, + proxyPort: number, + classicExchange: ClassicExchange, + dohExchange: DohExchange, + tcpExchange: TcpExchange, +) { + const id = crypto.randomInt(0, 65_536); + const packet = buildDnsQuery(hostname, type, id); + const exchange = resolver.kind === 'doh' ? dohExchange : classicExchange; + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + let result = await exchange({ + path, resolver, packet, proxyPort, + timeoutMs: resolver.kind === 'doh' ? DOH_TIMEOUT_MS : CLASSIC_TIMEOUT_MS, + }); + let parsed = parseDnsResponse(result.response, id); + if (resolver.kind === 'dns' && parsed.truncated) { + result = await tcpExchange({ path, resolver, packet, proxyPort, timeoutMs: CLASSIC_TIMEOUT_MS }); + parsed = parseDnsResponse(result.response, id); + } + return { ...parsed, latencyMs: result.latencyMs, transport: result.transport }; + } catch (error) { + lastError = error; + if (errorCode(error) !== 'timeout') break; + } + } + throw lastError; +} + +async function queryPath( + hostname: string, + path: PathKind, + resolver: DnsResolverDefinition, + proxyPort: number, + classicExchange: ClassicExchange, + dohExchange: DohExchange, + tcpExchange: TcpExchange, +): Promise { + const settled = await Promise.allSettled((['A', 'AAAA'] as RecordKind[]).map((type) => ( + queryType(hostname, type, path, resolver, proxyPort, classicExchange, dohExchange, tcpExchange) + ))); + const values = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []); + const errors = settled.flatMap((result) => result.status === 'rejected' ? [errorCode(result.reason)] : []); + const rcodes = [...new Set(values.map(({ rcode }) => rcode))]; + const ipv4 = [...new Set(values.flatMap((value) => value.ipv4))]; + const ipv6 = [...new Set(values.flatMap((value) => value.ipv6))]; + const rcode = rcodes.find((value) => value !== 'NOERROR') || rcodes[0] || null; + const status = ipv4.length || ipv6.length + ? 'answered' + : rcode === 'NXDOMAIN' + ? 'nxdomain' + : rcode === 'SERVFAIL' + ? 'servfail' + : values.length + ? 'no-addresses' + : errors.every((error) => error === 'timeout') + ? 'timeout' + : 'error'; + return { + status, + rcode, + ipv4, + ipv6, + latencyMs: values.length ? Math.max(...values.map(({ latencyMs }) => latencyMs)) : null, + transport: [...new Set(values.map(({ transport }) => transport))].join('+') || null, + error: errors.length ? [...new Set(errors)].join('; ') : null, + }; +} + +function addressSet(result: QueryPathResult) { + return [...result.ipv4, ...result.ipv6].sort(); +} + +function comparison(direct: QueryPathResult, vpn: QueryPathResult) { + const directOk = direct.status === 'answered' || direct.status === 'no-addresses' || direct.status === 'nxdomain'; + const vpnOk = vpn.status === 'answered' || vpn.status === 'no-addresses' || vpn.status === 'nxdomain'; + if (!directOk && !vpnOk) return 'failed'; + if (directOk && !vpnOk) return 'direct-only'; + if (!directOk && vpnOk) return 'vpn-only'; + return JSON.stringify(addressSet(direct)) === JSON.stringify(addressSet(vpn)) && direct.rcode === vpn.rcode + ? 'same' + : 'different'; +} + +export function createDnsDiagnosticsService({ + proxyPort, + getServers = dnsGetServers, + lookup = dnsLookup, + classicExchange = defaultClassicExchange, + dohExchange = defaultDohExchange, + tcpExchange = tcpFallback, + now = () => new Date().toISOString(), +}: { + proxyPort: number; + getServers?: () => string[]; + lookup?: typeof dnsLookup; + classicExchange?: ClassicExchange; + dohExchange?: DohExchange; + tcpExchange?: TcpExchange; + now?: () => string; +}) { + async function catalog(customResolvers: CustomDnsResolver[] = [], customDomains: CustomDnsDomain[] = []) { + return { + resolvers: availableResolvers(customResolvers, getServers), + domains: domains(customDomains), + }; + } + + async function run({ + vpnAvailable, + customResolvers = [], + customDomains = [], + domainId, + resolverId = null, + }: { + vpnAvailable: boolean; + customResolvers?: CustomDnsResolver[]; + customDomains?: CustomDnsDomain[]; + domainId: unknown; + resolverId?: unknown; + }) { + const available = await catalog(customResolvers, customDomains); + const domain = available.domains.find(({ id }) => id === domainId); + if (!domain) throw new Error('Unknown DNS diagnostic domain'); + const selected = resolverId + ? available.resolvers.filter(({ id }) => id === resolverId) + : available.resolvers; + if (!selected.length) throw new Error('Unknown DNS diagnostic resolver'); + const results: unknown[] = new Array(selected.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_RESOLVERS, selected.length) }, async () => { + while (cursor < selected.length) { + const index = cursor; + cursor += 1; + const catalogResolver = selected[index]; + let resolver: DnsResolverDefinition; + try { + resolver = await prepareResolver(catalogResolver, lookup); + } catch (error) { + const unavailable: QueryPathResult = { + status: 'error', rcode: null, ipv4: [], ipv6: [], latencyMs: null, + transport: null, error: errorCode(error), + }; + results[index] = { + resolver: catalogResolver, + direct: unavailable, + vpn: vpnAvailable ? unavailable : { ...unavailable, status: 'vpn-off', error: 'VPN выключен' }, + comparison: 'failed', + warning: null, + }; + continue; + } + const directPromise = queryPath(domain.hostname, 'direct', resolver, proxyPort, classicExchange, dohExchange, tcpExchange); + const vpnPromise = vpnAvailable + ? queryPath(domain.hostname, 'vpn', resolver, proxyPort, classicExchange, dohExchange, tcpExchange) + : Promise.resolve({ + status: 'vpn-off', rcode: null, ipv4: [], ipv6: [], latencyMs: null, + transport: null, error: 'VPN выключен', + }); + const [direct, vpn] = await Promise.all([directPromise, vpnPromise]); + const addresses = [...direct.ipv4, ...direct.ipv6, ...vpn.ipv4, ...vpn.ipv6]; + results[index] = { + resolver: catalogResolver, + direct, + vpn, + comparison: comparison(direct, vpn), + warning: domain.builtIn && addresses.some((address) => !isPublicAddress(address)) + ? 'private-address' + : null, + }; + } + })); + return { checkedAt: now(), domain, results }; + } + + return { catalog, run }; +} + +export type DnsDiagnosticsService = ReturnType; diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts index eed6f31..39ea276 100644 --- a/src/server/services/stateStore.ts +++ b/src/server/services/stateStore.ts @@ -13,7 +13,7 @@ import { type NormalizedServer, } from '../../shared/serverIdentity.js'; -export const STATE_SCHEMA_VERSION = 9; +export const STATE_SCHEMA_VERSION = 10; export interface AtomicWriteOptions { beforeRename?: (temporaryPath: string, filePath: string) => void; diff --git a/src/shared/connectivityDiagnostics.ts b/src/shared/connectivityDiagnostics.ts index c2c2ec6..cd4101f 100644 --- a/src/shared/connectivityDiagnostics.ts +++ b/src/shared/connectivityDiagnostics.ts @@ -24,6 +24,40 @@ export const CONNECTIVITY_SITES = Object.freeze([ ]); export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5; +export const MAX_CUSTOM_DNS_RESOLVERS = 5; +export const MAX_CUSTOM_DNS_DOMAINS = 5; + +export const DNS_DIAGNOSTIC_DOMAINS = Object.freeze([ + { id: 'youtube', label: 'YouTube', hostname: 'www.youtube.com', builtIn: true }, + { id: 'chatgpt', label: 'ChatGPT', hostname: 'chatgpt.com', builtIn: true }, +]); + +export const DNS_DIAGNOSTIC_RESOLVERS = Object.freeze([ + { id: 'google-dns', label: 'Google DNS', kind: 'dns', endpoint: '8.8.8.8' }, + { + id: 'google-doh', + label: 'Google DoH', + kind: 'doh', + endpoint: 'https://dns.google/dns-query', + bootstrap: '8.8.8.8', + }, + { id: 'cloudflare-dns', label: 'Cloudflare DNS', kind: 'dns', endpoint: '1.1.1.1' }, + { + id: 'cloudflare-doh', + label: 'Cloudflare DoH', + kind: 'doh', + endpoint: 'https://cloudflare-dns.com/dns-query', + bootstrap: '1.1.1.1', + }, + { id: 'yandex-dns', label: 'Яндекс DNS', kind: 'dns', endpoint: '77.88.8.8' }, + { + id: 'yandex-doh', + label: 'Яндекс DoH', + kind: 'doh', + endpoint: 'https://common.dot.dns.yandex.net/dns-query', + bootstrap: '77.88.8.8', + }, +] satisfies DnsResolverDefinition[]); export interface DiagnosticService { id: string; @@ -31,10 +65,43 @@ export interface DiagnosticService { url: string; } +export interface DnsResolverDefinition { + id: string; + label: string; + kind: 'dns' | 'doh'; + endpoint: string; + bootstrap?: string; + system?: boolean; + custom?: boolean; +} + +export interface DnsDomainDefinition { + id: string; + label: string; + hostname: string; + builtIn?: boolean; + custom?: boolean; +} + +export interface CustomDnsResolver { + id: string; + label: string; + kind: 'dns' | 'doh'; + endpoint: string; +} + +export interface CustomDnsDomain { + id: string; + label: string; + hostname: string; +} + export interface DiagnosticSettings { configured: boolean; customServices: DiagnosticService[]; hiddenServiceIds: string[]; + customDnsResolvers: CustomDnsResolver[]; + customDnsDomains: CustomDnsDomain[]; } function record(value: unknown): Record { @@ -62,6 +129,65 @@ function diagnosticService(value: unknown): DiagnosticService | null { } } +function ipLiteral(value: string) { + if (!value || /[\s/?#@\[\]]/.test(value)) return false; + try { + const parsed = new URL(`http://${value.includes(':') ? `[${value}]` : value}/`); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + return hostname === value.toLowerCase() && (value.includes(':') || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)); + } catch { + return false; + } +} + +function dohEndpoint(value: string) { + try { + const parsed = new URL(value); + if ( + parsed.protocol !== 'https:' + || parsed.username + || parsed.password + || parsed.search + || parsed.hash + || (parsed.port && parsed.port !== '443') + ) return null; + return parsed.href; + } catch { + return null; + } +} + +function customDnsResolver(value: unknown): CustomDnsResolver | null { + const candidate = record(value); + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const kind = candidate.kind === 'dns' || candidate.kind === 'doh' ? candidate.kind : null; + const rawEndpoint = typeof candidate.endpoint === 'string' ? candidate.endpoint.trim() : ''; + if (!/^custom-dns-[a-z0-9-]{1,72}$/i.test(id) || !label || label.length > 40 || !kind) return null; + const endpoint = kind === 'dns' ? (ipLiteral(rawEndpoint) ? rawEndpoint.toLowerCase() : null) : dohEndpoint(rawEndpoint); + return endpoint ? { id, label, kind, endpoint } : null; +} + +function normalizedHostname(value: unknown) { + const text = typeof value === 'string' ? value.trim().replace(/\.$/, '') : ''; + if (!text || text.length > 253 || /[\s/?#@:]/.test(text)) return null; + try { + const hostname = new URL(`http://${text}/`).hostname.toLowerCase(); + return hostname && hostname.length <= 253 ? hostname : null; + } catch { + return null; + } +} + +function customDnsDomain(value: unknown): CustomDnsDomain | null { + const candidate = record(value); + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const hostname = normalizedHostname(candidate.hostname); + if (!/^custom-domain-[a-z0-9-]{1,68}$/i.test(id) || !label || label.length > 40 || !hostname) return null; + return { id, label, hostname }; +} + export function normalizeDiagnosticSettings( value: unknown, { strict = false }: { strict?: boolean } = {}, @@ -78,17 +204,35 @@ export function normalizeDiagnosticSettings( const hiddenServiceIds = requestedHiddenIds .filter((id): id is string => typeof id === 'string' && builtInIds.has(id)) .filter((id, index, ids) => ids.indexOf(id) === index); + const requestedDnsResolvers = Array.isArray(candidate.customDnsResolvers) ? candidate.customDnsResolvers : []; + const customDnsResolvers = requestedDnsResolvers + .map(customDnsResolver) + .filter((resolver): resolver is CustomDnsResolver => Boolean(resolver)) + .filter((resolver, index, resolvers) => resolvers.findIndex(({ id }) => id === resolver.id) === index) + .slice(0, MAX_CUSTOM_DNS_RESOLVERS); + const requestedDnsDomains = Array.isArray(candidate.customDnsDomains) ? candidate.customDnsDomains : []; + const customDnsDomains = requestedDnsDomains + .map(customDnsDomain) + .filter((domain): domain is CustomDnsDomain => Boolean(domain)) + .filter((domain, index, domains) => domains.findIndex(({ id }) => id === domain.id) === index) + .slice(0, MAX_CUSTOM_DNS_DOMAINS); if (strict && ( typeof candidate.configured !== 'boolean' || !Array.isArray(candidate.customServices) || !Array.isArray(candidate.hiddenServiceIds) || customServices.length !== requestedServices.length || hiddenServiceIds.length !== requestedHiddenIds.length + || (candidate.customDnsResolvers !== undefined && !Array.isArray(candidate.customDnsResolvers)) + || (candidate.customDnsDomains !== undefined && !Array.isArray(candidate.customDnsDomains)) + || customDnsResolvers.length !== requestedDnsResolvers.length + || customDnsDomains.length !== requestedDnsDomains.length )) throw new TypeError('Invalid diagnostic settings'); return { configured: candidate.configured === true, customServices, hiddenServiceIds, + customDnsResolvers, + customDnsDomains, }; } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 37aab49..f357f18 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.35.1', - gatewayClient: '0.37.1', - gatewayBackend: '0.37.0', + macClient: '0.36.0', + gatewayClient: '0.38.0', + gatewayBackend: '0.38.0', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index 9386691..97506af 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -28,6 +28,8 @@ const componentActions = { setDevicePolicy: api.devices.setPolicy, pingServers: api.servers.ping, runConnectivityDiagnostics: api.diagnostics.connectivity, + loadDnsDiagnosticsCatalog: api.diagnostics.dnsCatalog, + runDnsDiagnostics: api.diagnostics.dns, loadActivityJournal: api.activityJournal.page, loadLiveTraffic: api.traffic.live, }; diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index 11500f3..936fda0 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -202,6 +202,14 @@ export const api = { body: JSON.stringify({ target }), }, ), + dnsCatalog: () => request('/api/diagnostics/dns'), + dns: (domainId: string, resolverId: string | null = null) => request( + '/api/diagnostics/dns', + { + method: 'POST', + body: JSON.stringify({ domainId, resolverId }), + }, + ), updateSettings: (settings: unknown, expectedRevision: number) => request( '/api/diagnostics/settings', { diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 489265c..da80c14 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -132,6 +132,8 @@ interface ComponentActions { setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise; pingServers: (profileId: string, ids: string[]) => Promise; runConnectivityDiagnostics: (target?: unknown) => Promise; + loadDnsDiagnosticsCatalog: () => Promise; + runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise; loadActivityJournal: (cursor?: string | null) => Promise; loadLiveTraffic: () => Promise; } @@ -968,6 +970,8 @@ export function ClientOverviewPage({ {diagnosticsAvailable && Promise; -type UpdateSettings = (settings: Pick) => Promise; +type UpdateSettings = (settings: Partial) => Promise; function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -236,12 +237,16 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity export function ConnectivityDiagnosticsPanel({ feature, runConnectivityDiagnostics, + loadDnsDiagnosticsCatalog, + runDnsDiagnostics, settings, updateSettings, isGateway, }: { feature: DiagnosticsFeature; runConnectivityDiagnostics: RunConnectivityDiagnostics; + loadDnsDiagnosticsCatalog: () => Promise; + runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise; settings: DiagnosticSettings; updateSettings: UpdateSettings; isGateway: boolean; @@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({ + + ); } diff --git a/src/web/features/diagnostics/DnsDiagnosticsSection.tsx b/src/web/features/diagnostics/DnsDiagnosticsSection.tsx new file mode 100644 index 0000000..4f97e1c --- /dev/null +++ b/src/web/features/diagnostics/DnsDiagnosticsSection.tsx @@ -0,0 +1,444 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react'; + +import { + MAX_CUSTOM_DNS_DOMAINS, + MAX_CUSTOM_DNS_RESOLVERS, + type DiagnosticSettings, + type DnsDomainDefinition, + type DnsResolverDefinition, +} from '../../../shared/connectivityDiagnostics.js'; +import { Tooltip } from '../../ui/Tooltip.js'; + +interface DnsPathResult { + status: string; + rcode: string | null; + ipv4: string[]; + ipv6: string[]; + latencyMs: number | null; + transport: string | null; + error: string | null; +} + +interface DnsRowResult { + resolver: DnsResolverDefinition; + direct: DnsPathResult; + vpn: DnsPathResult; + comparison: 'same' | 'different' | 'direct-only' | 'vpn-only' | 'failed'; + warning: string | null; +} + +interface DnsRunResult { + checkedAt: string; + domain: DnsDomainDefinition; + results: DnsRowResult[]; +} + +interface DnsCatalog { + domains: DnsDomainDefinition[]; + resolvers: DnsResolverDefinition[]; +} + +type UpdateSettings = (settings: Partial) => Promise; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function stringArray(value: unknown) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) throw new TypeError('Invalid DNS addresses'); + return value as string[]; +} + +function resolver(value: unknown): DnsResolverDefinition { + const item = record(value); + if ( + typeof item.id !== 'string' || !item.id + || typeof item.label !== 'string' || !item.label + || !['dns', 'doh'].includes(String(item.kind)) + || typeof item.endpoint !== 'string' || !item.endpoint + ) throw new TypeError('Invalid DNS resolver'); + return { + id: item.id, + label: item.label, + kind: item.kind as 'dns' | 'doh', + endpoint: item.endpoint, + ...(item.bootstrap ? { bootstrap: String(item.bootstrap) } : {}), + system: item.system === true, + custom: item.custom === true, + }; +} + +function domain(value: unknown): DnsDomainDefinition { + const item = record(value); + if ( + typeof item.id !== 'string' || !item.id + || typeof item.label !== 'string' || !item.label + || typeof item.hostname !== 'string' || !item.hostname + ) throw new TypeError('Invalid DNS domain'); + return { + id: item.id, + label: item.label, + hostname: item.hostname, + builtIn: item.builtIn === true, + custom: item.custom === true, + }; +} + +function pathResult(value: unknown): DnsPathResult { + const item = record(value); + if ( + typeof item.status !== 'string' + || !(item.rcode === null || typeof item.rcode === 'string') + || !(item.latencyMs === null || Number.isFinite(item.latencyMs)) + || !(item.transport === null || typeof item.transport === 'string') + || !(item.error === null || typeof item.error === 'string') + ) throw new TypeError('Invalid DNS path result'); + return { + status: item.status, + rcode: item.rcode as string | null, + ipv4: stringArray(item.ipv4), + ipv6: stringArray(item.ipv6), + latencyMs: item.latencyMs as number | null, + transport: item.transport as string | null, + error: item.error as string | null, + }; +} + +function parseCatalog(value: unknown): DnsCatalog { + const item = record(value); + if (!Array.isArray(item.domains) || !Array.isArray(item.resolvers)) throw new TypeError('Invalid DNS catalog'); + return { domains: item.domains.map(domain), resolvers: item.resolvers.map(resolver) }; +} + +function parseRun(value: unknown): DnsRunResult { + const item = record(value); + if (typeof item.checkedAt !== 'string' || !Array.isArray(item.results)) throw new TypeError('Invalid DNS result'); + return { + checkedAt: item.checkedAt, + domain: domain(item.domain), + results: item.results.map((value) => { + const row = record(value); + if (!['same', 'different', 'direct-only', 'vpn-only', 'failed'].includes(String(row.comparison))) { + throw new TypeError('Invalid DNS comparison'); + } + return { + resolver: resolver(row.resolver), + direct: pathResult(row.direct), + vpn: pathResult(row.vpn), + comparison: row.comparison as DnsRowResult['comparison'], + warning: row.warning === null ? null : String(row.warning), + }; + }), + }; +} + +function requestMessage(value: unknown) { + const item = record(value); + return typeof item.message === 'string' ? item.message : 'DNS checker временно недоступен.'; +} + +function Refresh({ + label, + running, + disabled, + compact = false, + onClick, +}: { + label: string; + running: boolean; + disabled: boolean; + compact?: boolean; + onClick: () => void; +}) { + return + + {label} + ; +} + +function pathStatus(result: DnsPathResult | undefined) { + if (!result) return '—'; + const labels: Record = { + 'vpn-off': 'VPN выключен', + timeout: 'Timeout', + nxdomain: 'NXDOMAIN', + servfail: 'SERVFAIL', + error: 'Ошибка ответа', + 'no-addresses': result.rcode || 'Нет адресов', + }; + return labels[result.status] || result.error || result.rcode || ''; +} + +function ResultCell({ result, running, route }: { + result: DnsPathResult | undefined; + running: boolean; + route: string; +}) { + const failed = result && !['answered', 'no-addresses', 'nxdomain'].includes(result.status); + return + A{running && !result ? 'Тестируем' : result?.ipv4.join(', ') || '—'} + AAAA{running && !result ? 'Тестируем' : result?.ipv6.join(', ') || '—'} + {running + ? 'Тестируем' + : result?.latencyMs !== null && result?.latencyMs !== undefined + ? `${result.latencyMs} мс · ${result.transport || 'DNS'}` + : pathStatus(result)} + ; +} + +const comparisonLabel: Record = { + same: 'совпадают', + different: 'различаются', + 'direct-only': 'только Direct', + 'vpn-only': 'только VPN', + failed: 'нет ответа', +}; + +function preserveAddresses(previous: DnsPathResult | undefined, next: DnsPathResult) { + return previous && !['answered', 'no-addresses', 'nxdomain'].includes(next.status) + ? { ...next, ipv4: previous.ipv4, ipv6: previous.ipv6 } + : next; +} + +function mergeRow(previous: DnsRowResult | undefined, next: DnsRowResult): DnsRowResult { + return previous ? { + ...next, + direct: preserveAddresses(previous.direct, next.direct), + vpn: preserveAddresses(previous.vpn, next.vpn), + } : next; +} + +export function DnsDiagnosticsSection({ + open, + settings, + updateSettings, + loadCatalog, + runDiagnostics, +}: { + open: boolean; + settings: DiagnosticSettings; + updateSettings: UpdateSettings; + loadCatalog: () => Promise; + runDiagnostics: (domainId: string, resolverId?: string | null) => Promise; +}) { + const [catalog, setCatalog] = useState(null); + const [activeDomainId, setActiveDomainId] = useState('youtube'); + const [cache, setCache] = useState>>({}); + const [running, setRunning] = useState(false); + const [activeResolverId, setActiveResolverId] = useState(null); + const [error, setError] = useState(''); + const [addingDomain, setAddingDomain] = useState(false); + const [domainName, setDomainName] = useState(''); + const [domainHostname, setDomainHostname] = useState(''); + const [addingResolver, setAddingResolver] = useState(false); + const [resolverName, setResolverName] = useState(''); + const [resolverEndpoint, setResolverEndpoint] = useState(''); + const [saving, setSaving] = useState(false); + const rows = useMemo(() => cache[activeDomainId] || {}, [cache, activeDomainId]); + + async function refreshCatalog() { + const next = parseCatalog(await loadCatalog()); + setCatalog(next); + if (!next.domains.some(({ id }) => id === activeDomainId)) setActiveDomainId(next.domains[0]?.id || ''); + } + + useEffect(() => { + if (!open || catalog) return; + void refreshCatalog().catch((requestError) => setError(requestMessage(requestError))); + }, [open, catalog]); + + async function run(resolverId: string | null = null) { + if (!catalog || !activeDomainId) return; + setRunning(true); + setActiveResolverId(resolverId); + setError(''); + try { + const result = parseRun(await runDiagnostics(activeDomainId, resolverId)); + setCache((current) => { + const previous = current[activeDomainId] || {}; + const incoming = Object.fromEntries(result.results.map((row) => [ + row.resolver.id, + mergeRow(previous[row.resolver.id], row), + ])); + return { ...current, [activeDomainId]: resolverId ? { ...previous, ...incoming } : incoming }; + }); + } catch (requestError) { + setError(requestMessage(requestError)); + } finally { + setRunning(false); + setActiveResolverId(null); + } + } + + async function saveDomain(event: FormEvent) { + event.preventDefault(); + if (settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS) return; + setSaving(true); + setError(''); + try { + const hostname = new URL(`http://${domainHostname.trim().replace(/\.$/, '')}/`).hostname; + const id = `custom-domain-${globalThis.crypto?.randomUUID?.() || Date.now()}`; + const saved = await updateSettings({ + customDnsDomains: [...settings.customDnsDomains, { + id, + label: domainName.trim() || hostname, + hostname, + }], + }); + if (saved === false) throw new Error('Не удалось сохранить домен.'); + setDomainName(''); + setDomainHostname(''); + setAddingDomain(false); + setCatalog(null); + setActiveDomainId(id); + } catch (validationError) { + setError(validationError instanceof Error ? validationError.message : 'Проверьте домен.'); + } finally { + setSaving(false); + } + } + + async function saveResolver(event: FormEvent) { + event.preventDefault(); + if (settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS) return; + setSaving(true); + setError(''); + try { + const endpoint = resolverEndpoint.trim(); + const kind = endpoint.startsWith('https://') ? 'doh' : 'dns'; + const saved = await updateSettings({ + customDnsResolvers: [...settings.customDnsResolvers, { + id: `custom-dns-${globalThis.crypto?.randomUUID?.() || Date.now()}`, + label: resolverName.trim() || endpoint, + kind, + endpoint, + }], + }); + if (saved === false) throw new Error('Не удалось сохранить DNS.'); + setResolverName(''); + setResolverEndpoint(''); + setAddingResolver(false); + setCatalog(null); + } catch (validationError) { + setError(validationError instanceof Error ? validationError.message : 'Проверьте DNS или DoH endpoint.'); + } finally { + setSaving(false); + } + } + + async function remove(kind: 'domain' | 'resolver', id: string) { + setSaving(true); + setError(''); + try { + const saved = await updateSettings(kind === 'domain' + ? { customDnsDomains: settings.customDnsDomains.filter((item) => item.id !== id) } + : { customDnsResolvers: settings.customDnsResolvers.filter((item) => item.id !== id) }); + if (saved === false) throw new Error('Не удалось сохранить DNS settings.'); + setCatalog(null); + setCache({}); + } catch (requestError) { + setError(requestMessage(requestError)); + } finally { + setSaving(false); + } + } + + const blocked = running || saving; + return
+
+ DNS + void run()} /> +
+ +
+ + + + {catalog?.domains.find(({ id }) => id === activeDomainId)?.custom && } +
+ + {addingDomain &&
+ setDomainName(event.target.value)} /> + setDomainHostname(event.target.value)} /> + + +
} + + {error &&
{error}
} + +
+
+ DNSНапрямуюVPN
+ {(catalog?.resolvers || []).map((item) => { + const result = rows[item.id]; + const rowRunning = running && (!activeResolverId || activeResolverId === item.id); + return
+ + {item.label}{item.kind === 'doh' ? 'DoH' : 'DNS'} · {item.endpoint}{result?.warning === 'private-address' ? 'получен частный адрес' : result ? comparisonLabel[result.comparison] : 'не проверено'} + void run(item.id)} /> + + + + {item.custom ? :
; + })} +
+ + {!catalog && !error &&

Загружаем DNS…

} +
+ +
+ {addingResolver &&
+ setResolverName(event.target.value)} /> + setResolverEndpoint(event.target.value)} /> + + +
} +

Разные ответы могут быть нормой для CDN.

+
; +} diff --git a/src/web/styles/features/diagnostics.css b/src/web/styles/features/diagnostics.css index 92f47b9..fa04b41 100644 --- a/src/web/styles/features/diagnostics.css +++ b/src/web/styles/features/diagnostics.css @@ -426,6 +426,205 @@ padding-inline: 8px; } +.client-dns-section { + margin-bottom: 8px; +} + +.client-dns-title-row > .client-diagnostics-refresh-wrap { + margin-left: auto; +} + +.client-dns-domain-row { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto 28px; + align-items: center; + gap: 8px; + min-height: 38px; + padding-inline: 8px; +} + +.client-dns-domain-row label, +.client-dns-domain-row button, +.client-dns-domain-row select, +.client-dns-inline-form input, +.client-dns-inline-form button { + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + +.client-dns-domain-row label { + color: var(--client-muted); +} + +.client-dns-domain-row select { + min-width: 0; + height: 34px; + border: 0; + outline: 0; + background: transparent; + color: var(--client-text); + box-shadow: 0 1px color-mix(in oklch, var(--client-border) 48%, transparent); +} + +.client-dns-domain-row select:focus-visible { + box-shadow: 0 1px var(--client-accent); +} + +.client-dns-domain-row button:not(.client-row-delete), +.client-dns-inline-form button { + padding: 4px 0; + border: 0; + background: transparent; + color: var(--client-accent); + cursor: pointer; +} + +.client-dns-domain-row button:disabled, +.client-dns-inline-form button:disabled { + color: var(--client-muted); + cursor: default; + opacity: 0.55; +} + +.client-dns-inline-form { + min-width: 0; + display: grid; + grid-template-columns: minmax(90px, 0.65fr) minmax(0, 1.35fr) auto 28px; + align-items: center; + gap: 8px; + min-height: 40px; + padding-inline: 8px; +} + +.client-dns-inline-form input { + min-width: 0; + height: 34px; + padding: 0 2px; + border: 0; + outline: 0; + background: transparent; + color: var(--client-text); + box-shadow: 0 1px transparent; +} + +.client-dns-inline-form input:focus { + box-shadow: 0 1px var(--client-accent); +} + +.client-dns-inline-form input::placeholder { + color: color-mix(in oklch, var(--client-muted) 70%, transparent); +} + +.client-dns-table { + display: grid; +} + +.client-dns-header, +.client-dns-row { + min-width: 0; + display: grid; + grid-template-columns: 35% minmax(0, 1fr) minmax(0, 1fr) 28px; + align-items: stretch; +} + +.client-dns-header { + padding: 7px 0; + color: var(--client-muted); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + +.client-dns-header > span, +.client-dns-row > span { + min-width: 0; + padding-inline: 8px; +} + +.client-dns-row { + position: relative; + min-height: 92px; + border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); +} + +.client-dns-row.is-running .client-dns-resolver { + color: var(--client-accent); + text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); +} + +.client-dns-resolver { + align-items: flex-start; + padding-block: 10px; + color: var(--client-text); + transition: color 420ms ease, text-shadow 520ms ease; +} + +.client-dns-resolver > span:first-child { + display: grid; + gap: 3px; +} + +.client-dns-resolver b, +.client-dns-resolver small, +.client-dns-result { + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + +.client-dns-resolver small { + overflow-wrap: anywhere; + color: var(--client-muted); +} + +.client-dns-result { + min-height: 72px; + display: grid; + align-content: center; + gap: 5px; + padding-block: 10px; + color: var(--client-text); + font-variant-numeric: var(--numeric-tabular); +} + +.client-dns-result > span { + overflow-wrap: anywhere; +} + +.client-dns-result b { + display: inline-block; + min-width: 42px; + margin-right: 5px; + color: var(--client-muted); +} + +.client-dns-result small { + min-height: 1em; + color: var(--client-muted); +} + +.client-dns-result.is-running small { + color: var(--client-accent); +} + +.client-dns-result.is-error small { + color: oklch(0.68 0.15 28); +} + +.client-dns-row > .client-row-delete { + align-self: center; +} + +.client-dns-note { + margin: 5px 8px 0; + color: var(--client-muted); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + @media (max-width: 560px) { .client-diagnostics-table th, .client-diagnostics-table td { @@ -461,4 +660,57 @@ width: 44px; height: 44px; } + + .client-dns-domain-row { + grid-template-columns: auto minmax(0, 1fr) 44px; + } + + .client-dns-domain-row label { + grid-column: 1; + } + + .client-dns-domain-row select { + grid-column: 1 / 3; + grid-row: 2; + } + + .client-dns-domain-row button:not(.client-row-delete) { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + + .client-dns-domain-row > .client-row-delete { + grid-column: 3; + grid-row: 1 / 3; + width: 44px; + height: 44px; + } + + .client-dns-inline-form { + grid-template-columns: minmax(0, 1fr) 44px; + padding-block: 5px; + } + + .client-dns-inline-form input, + .client-dns-inline-form button[type='submit'] { + grid-column: 1; + } + + .client-dns-inline-form button:last-child { + grid-column: 2; + grid-row: 1 / 4; + width: 44px; + height: 44px; + } + + .client-dns-header, + .client-dns-row { + grid-template-columns: 32% minmax(0, 1fr) minmax(0, 1fr) 44px; + } + + .client-dns-header > span, + .client-dns-row > span { + padding-inline: 5px; + } } diff --git a/test/server/connectivity-diagnostics.test.js b/test/server/connectivity-diagnostics.test.js index aa0cc0d..fcdb662 100644 --- a/test/server/connectivity-diagnostics.test.js +++ b/test/server/connectivity-diagnostics.test.js @@ -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 () => { diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js index 94c2c33..88429ea 100644 --- a/test/server/dataplane-client.test.js +++ b/test/server/dataplane-client.test.js @@ -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 () => { diff --git a/test/server/dns-diagnostics.test.js b/test/server/dns-diagnostics.test.js new file mode 100644 index 0000000..eed4249 --- /dev/null +++ b/test/server/dns-diagnostics.test.js @@ -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/); +}); diff --git a/test/server/state-store.test.js b/test/server/state-store.test.js index dfeabef..5c8dcfe 100644 --- a/test/server/state-store.test.js +++ b/test/server/state-store.test.js @@ -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 }], diff --git a/test/web/diagnostics-feature-contract.test.js b/test/web/diagnostics-feature-contract.test.js index 9c33a29..e57f94d 100644 --- a/test/web/diagnostics-feature-contract.test.js +++ b/test/web/diagnostics-feature-contract.test.js @@ -12,6 +12,7 @@ const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/Conn const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8'); const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8'); const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8'); +const dns = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8'); const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true }; const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 }; @@ -122,3 +123,19 @@ test('network identity is one stable compact row for Direct and VPN', () => { assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/); assert.doesNotMatch(panel, /traceroute|tracepath/); }); + +test('DNS diagnostics reuse the drawer owner, canonical settings and independent row refresh', () => { + assert.match(panel, / void run\(item\.id\)\}/); + assert.match(dns, /updateSettings\([\s\S]*customDnsDomains/); + assert.match(dns, /updateSettings\([\s\S]*customDnsResolvers/); + assert.match(dns, /settings\.customDnsDomains\.length >= MAX_CUSTOM_DNS_DOMAINS/); + assert.match(dns, /settings\.customDnsResolvers\.length >= MAX_CUSTOM_DNS_RESOLVERS/); + assert.match(dns, /A<\/b>[\s\S]*AAAA<\/b>/); + assert.match(dns, /Разные ответы могут быть нормой для CDN/); +}); diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index bab9251..46e8fbf 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -15,6 +15,7 @@ const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/Routin const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8'); const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8'); const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8'); +const dnsDiagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8'); const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8'); function rule(selector, source = styles) { @@ -264,6 +265,15 @@ test('responsive motion has a reduced-motion fallback', () => { assert.doesNotMatch(styles, /\.client-rail-action:active:not\(:disabled\) svg/); }); +test('DNS keeps three result columns while its add forms stack on narrow screens', () => { + assert.match(styles, /\.client-dns-row \{[\s\S]*min-height:\s*92px/); + assert.match(styles, /\.client-dns-result \{[\s\S]*min-height:\s*72px/); + assert.match(styles, /\.client-dns-header,\s*\.client-dns-row \{[\s\S]*grid-template-columns:\s*35% minmax\(0, 1fr\) minmax\(0, 1fr\) 28px/); + assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-inline-form \{[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\) 44px/); + assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-row \{[\s\S]*grid-template-columns:\s*32% minmax\(0, 1fr\) minmax\(0, 1fr\) 44px/); + assert.match(dnsDiagnostics, /role="table" aria-busy=\{running\}/); +}); + test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => { const tooltip = rule('.client-tooltip'); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index dcf11df..fcaa1ab 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -40,26 +40,26 @@ const expectedImports = [ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 1163, + cascadeEdges: 1188, customProperties: 115, - declarations: 4746, + declarations: 4862, important: 0, keyframes: 55, media: 23, - rules: 1288, - variableReferences: 1237, + rules: 1329, + variableReferences: 1270, }, hashes: { - cascadeEdges: 'c1b3eaa3d0ad08e3393cf90878f90e17a5401d79eed9072d06f39b7841d8bbfd', + cascadeEdges: '3d02c10ab8fd95f7eed306048e9b59ec391a0febaa9e3e235badcff3b042eef7', customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', - declarations: 'e668191e548093c02463e57b81eb6c12b826991a5e10f5191d87b73e682cd852', + declarations: '0f31e2cdeeab02b9e0a845a6b50ff7e3f035a441217a418e7b5465b5a296ae16', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd', - ruleDeclarationSequences: '2cc97550fecd8bbf46217dc378cc490fd41d977abcdcc4c93a18c7593ba42f53', - selectors: 'c7f4752d81bbeb04612eb6c79a42bcde875c762d81591b2cdf2fb2bbd7399ea2', - variableReferences: '69c03c059f9ce107bc8be0397cff90e1b8b3749b8e37ba8e231e2d6cdcd61e46', - witnesses: '99713805eb7a5c67427ace32775f0cc503b3b2ac63240db717e71b36d09ddd68', + ruleDeclarationSequences: '33a0dff2428a8e712776d40d796ea05d169544eb1af02ad350759061fb2a3c3c', + selectors: '5734c69a504c59025e2e6b1637cfb6d1905ca1ae358783d0916a694a973eea22', + variableReferences: '49ad724b2812128f5344fdd55b177aa96ea9b30eb4d878dc86f2e4f3fa898182', + witnesses: '4e357ee4f7310d60399baa3099bd8f4ed320debc1e3c9a09f55b892a1178049c', }, }; @@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner', test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 1312); + assert.equal(witnesses.length, 1375); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts); @@ -221,11 +221,7 @@ test('accepted stylesheet has pinned declaration, selector, keyframe, variable, test('every live production selector has an expanded DOM witness', () => { const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root)); - assert.deepEqual(unmatched, [ - '.client-diagnostics-section-title button', - '.client-diagnostics-section-title button:disabled', - '.client-diagnostics-section-title button:focus-visible', - ]); + assert.deepEqual(unmatched, []); }); test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => { @@ -409,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); - assert.deepEqual(assets, ['index-BLRRtFYh.css']); + assert.deepEqual(assets, ['index-fllV-PfI.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 180634); - assert.equal(sha256(built), '6bf3cb9c724758de39567c606c939e6041edde82837f826e8d2d4e31ebd1d8a8'); + assert.equal(built.byteLength, 184958); + assert.equal(sha256(built), '3a9ed6fdf6c5d81234de9aa0abd995bc9db1cfe298194162654edcbfe3ae7c62'); }); diff --git a/test/web/ui-foundations-contract.test.js b/test/web/ui-foundations-contract.test.js index 386be8e..8a6f5cf 100644 --- a/test/web/ui-foundations-contract.test.js +++ b/test/web/ui-foundations-contract.test.js @@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => { .map(({ source }) => source) .join('\n'); - assert.equal((production.match(/