Add DNS diagnostics across dataplane and client
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-09-01 04:16:15 +03:00
parent e0bdafd25e
commit 76a99f098a
23 changed files with 1888 additions and 35 deletions
+23
View File
@@ -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');
+17
View File
@@ -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)
@@ -27,6 +27,13 @@ interface ConnectivityDiagnosticsDependencies {
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
dnsCatalog(customResolvers: unknown, customDomains: unknown): Promise<unknown>;
runDnsDiagnostics(
customResolvers: unknown,
customDomains: unknown,
domainId: unknown,
resolverId: unknown,
): Promise<unknown>;
}
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<string, unknown>
: {};
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 }));
},
@@ -4,7 +4,7 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
import { sendJson } from '../response.js';
interface ConnectivityDiagnosticsRouteDependencies {
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'dnsCatalog' | 'runDns' | 'updateSettings'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
sendState(res: ServerResponse): Promise<void>;
}
@@ -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);
+21
View File
@@ -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,
@@ -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<WireExchange>;
type DohExchange = (request: ExchangeRequest) => Promise<WireExchange>;
type TcpExchange = (request: ExchangeRequest) => Promise<WireExchange>;
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<number>();
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<Buffer>((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<WireExchange> {
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<WireExchange> {
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<QueryPathResult> {
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<QueryPathResult>({
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<typeof createDnsDiagnosticsService>;
+1 -1
View File
@@ -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;
+144
View File
@@ -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<string, unknown> {
@@ -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,
};
}
+3 -3
View File
@@ -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 {
+2
View File
@@ -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,
};
+8
View File
@@ -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',
{
@@ -132,6 +132,8 @@ interface ComponentActions {
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
}
@@ -968,6 +970,8 @@ export function ClientOverviewPage({
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
loadDnsDiagnosticsCatalog={actions.loadDnsDiagnosticsCatalog}
runDnsDiagnostics={actions.runDnsDiagnostics}
settings={state.diagnostics}
updateSettings={onUpdateDiagnosticsSettings}
isGateway={isGateway}
@@ -24,6 +24,7 @@ import {
} from './connectivityResult.js';
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
import { saveCustomDiagnosticService } from './customServiceAction.js';
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
@@ -36,7 +37,7 @@ interface IpSourceDefinition {
type StatusValue = [className: string, label: string];
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
function record(value: unknown): value is Record<string, unknown> {
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<unknown>;
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
settings: DiagnosticSettings;
updateSettings: UpdateSettings;
isGateway: boolean;
@@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({
</div>
</section>
<DnsDiagnosticsSection
open={open}
settings={settings}
updateSettings={updateSettings}
loadCatalog={loadDnsDiagnosticsCatalog}
runDiagnostics={runDnsDiagnostics}
/>
</Drawer>
);
}
@@ -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<DiagnosticSettings>) => Promise<unknown>;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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 <span className={`client-diagnostics-refresh-wrap client-tooltip-anchor${compact ? ' client-diagnostics-row-refresh-wrap' : ''}`}>
<button
className={`client-diagnostics-refresh${compact ? ' client-diagnostics-row-refresh' : ''}${running ? ' is-running' : ''}`}
type="button"
aria-label={label}
aria-busy={running}
disabled={disabled}
onClick={onClick}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
</svg>
</button>
<Tooltip>{label}</Tooltip>
</span>;
}
function pathStatus(result: DnsPathResult | undefined) {
if (!result) return '—';
const labels: Record<string, string> = {
'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 <span
className={`client-dns-result${failed ? ' is-error' : ''}${running ? ' is-running' : ''}`}
aria-label={`${route}: ${running ? 'Тестируем' : pathStatus(result)}`}
>
<span><b>A</b>{running && !result ? 'Тестируем' : result?.ipv4.join(', ') || '—'}</span>
<span><b>AAAA</b>{running && !result ? 'Тестируем' : result?.ipv6.join(', ') || '—'}</span>
<small>{running
? 'Тестируем'
: result?.latencyMs !== null && result?.latencyMs !== undefined
? `${result.latencyMs} мс · ${result.transport || 'DNS'}`
: pathStatus(result)}</small>
</span>;
}
const comparisonLabel: Record<DnsRowResult['comparison'], string> = {
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<unknown>;
runDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
}) {
const [catalog, setCatalog] = useState<DnsCatalog | null>(null);
const [activeDomainId, setActiveDomainId] = useState('youtube');
const [cache, setCache] = useState<Record<string, Record<string, DnsRowResult>>>({});
const [running, setRunning] = useState(false);
const [activeResolverId, setActiveResolverId] = useState<string | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 <section className="client-diagnostics-section client-dns-section" aria-labelledby="diagnostic-dns-title">
<div className="client-diagnostics-section-title client-dns-title-row">
<span id="diagnostic-dns-title">DNS</span>
<Refresh label="Проверить DNS" running={running && !activeResolverId} disabled={blocked || !catalog} onClick={() => void run()} />
</div>
<div className="client-dns-domain-row">
<label htmlFor="client-dns-domain">Домен</label>
<select
id="client-dns-domain"
value={activeDomainId}
disabled={blocked || !catalog?.domains.length}
onChange={(event) => setActiveDomainId(event.target.value)}
>
{(catalog?.domains || []).map((item) => <option key={item.id} value={item.id}>{item.label} · {item.hostname}</option>)}
</select>
<button
type="button"
disabled={blocked || addingDomain || settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS}
onClick={() => setAddingDomain(true)}
>+ Домен</button>
{catalog?.domains.find(({ id }) => id === activeDomainId)?.custom && <button
className="client-row-delete"
type="button"
aria-label="Удалить выбранный домен"
disabled={blocked}
onClick={() => void remove('domain', activeDomainId)}
>×</button>}
</div>
{addingDomain && <form className="client-dns-inline-form" onSubmit={saveDomain}>
<input value={domainName} maxLength={40} placeholder="Название" aria-label="Название DNS-домена" onChange={(event) => setDomainName(event.target.value)} />
<input value={domainHostname} placeholder="example.com" aria-label="DNS-домен" required onChange={(event) => setDomainHostname(event.target.value)} />
<button type="submit" disabled={blocked}>Добавить</button>
<button type="button" aria-label="Отменить добавление домена" onClick={() => setAddingDomain(false)}>×</button>
</form>}
{error && <div className="client-diagnostics-error" role="alert"><span>{error}</span></div>}
<div className="client-dns-table" role="table" aria-busy={running}>
<div className="client-dns-header" role="row">
<span role="columnheader">DNS</span><span role="columnheader">Напрямую</span><span role="columnheader">VPN</span><span aria-hidden="true" />
</div>
{(catalog?.resolvers || []).map((item) => {
const result = rows[item.id];
const rowRunning = running && (!activeResolverId || activeResolverId === item.id);
return <div key={item.id} className={`client-dns-row${rowRunning ? ' is-running' : ''}`} role="row" data-diagnostic-target={`dns:${item.id}`}>
<span className="client-dns-resolver client-diagnostics-row-name" role="rowheader">
<span><b>{item.label}</b><small>{item.kind === 'doh' ? 'DoH' : 'DNS'} · {item.endpoint}</small><small>{result?.warning === 'private-address' ? 'получен частный адрес' : result ? comparisonLabel[result.comparison] : 'не проверено'}</small></span>
<Refresh label={`Проверить: ${item.label}`} running={rowRunning && Boolean(activeResolverId)} disabled={blocked} compact onClick={() => void run(item.id)} />
</span>
<span role="cell"><ResultCell result={result?.direct} running={rowRunning} route={`Напрямую, ${item.label}`} /></span>
<span role="cell"><ResultCell result={result?.vpn} running={rowRunning} route={`VPN, ${item.label}`} /></span>
{item.custom ? <button
className="client-row-delete"
type="button"
aria-label={`Удалить DNS ${item.label}`}
disabled={blocked}
onClick={() => void remove('resolver', item.id)}
>×</button> : <span aria-hidden="true" />}
</div>;
})}
</div>
{!catalog && !error && <p className="client-diagnostics-services-empty">Загружаем DNS</p>}
<div className="client-row-add-slot client-diagnostics-add-slot">
<button
className="client-row-add"
type="button"
disabled={blocked || addingResolver || settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS}
onClick={() => setAddingResolver(true)}
>{settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS ? 'Лимит 5 DNS' : '+ Добавить DNS'}</button>
</div>
{addingResolver && <form className="client-dns-inline-form" onSubmit={saveResolver}>
<input value={resolverName} maxLength={40} placeholder="Название" aria-label="Название DNS-резолвера" onChange={(event) => setResolverName(event.target.value)} />
<input value={resolverEndpoint} placeholder="8.8.8.8 или https://…/dns-query" aria-label="DNS или DoH endpoint" required onChange={(event) => setResolverEndpoint(event.target.value)} />
<button type="submit" disabled={blocked}>Добавить</button>
<button type="button" aria-label="Отменить добавление DNS" onClick={() => setAddingResolver(false)}>×</button>
</form>}
<p className="client-dns-note">Разные ответы могут быть нормой для CDN.</p>
</section>;
}
+252
View File
@@ -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;
}
}