Add DNS diagnostics across dataplane and client
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user