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 { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import {
|
import {
|
||||||
createDomainTrafficService,
|
createDomainTrafficService,
|
||||||
readSingboxConnections,
|
readSingboxConnections,
|
||||||
@@ -57,6 +58,9 @@ const devicePolicy = createDevicePolicyService({
|
|||||||
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||||
proxyPort: settings.diagnosticsProxyPort,
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
});
|
});
|
||||||
|
const dnsDiagnostics = createDnsDiagnosticsService({
|
||||||
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
|
});
|
||||||
const failoverDiagnostics = {
|
const failoverDiagnostics = {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
@@ -292,6 +296,25 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
target,
|
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') {
|
if (req.method === 'POST' && req.url === '/failover/probe') {
|
||||||
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
||||||
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
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 });
|
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),
|
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
||||||
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||||
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
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;
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
};
|
};
|
||||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
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 {
|
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) {
|
updateSettings(settings: unknown, expectedRevision: unknown) {
|
||||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
let diagnostics: DiagnosticSettings;
|
let diagnostics: DiagnosticSettings;
|
||||||
try {
|
try {
|
||||||
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
||||||
? settings as Record<string, unknown>
|
? settings as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
|
diagnostics = normalizeDiagnosticSettings({
|
||||||
|
...normalizeDiagnosticSettings(current.diagnostics),
|
||||||
|
...requested,
|
||||||
|
configured: true,
|
||||||
|
}, { strict: true });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('REQUEST_INVALID', { 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;
|
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
||||||
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
|
|||||||
import { sendJson } from '../response.js';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface ConnectivityDiagnosticsRouteDependencies {
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'dnsCatalog' | 'runDns' | 'updateSettings'>;
|
||||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
sendState(res: ServerResponse): Promise<void>;
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,15 @@ export function createConnectivityDiagnosticsRoute(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
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') {
|
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||||
const { target = null } = await dependencies.readBody(req);
|
const { target = null } = await dependencies.readBody(req);
|
||||||
const result = await dependencies.diagnostics.run(target);
|
const result = await dependencies.diagnostics.run(target);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
} from './services/deviceInventoryService.js';
|
} from './services/deviceInventoryService.js';
|
||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import { createStateService } from './features/state/stateService.js';
|
import { createStateService } from './features/state/stateService.js';
|
||||||
import { createStateRoute } from './http/routes/stateRoute.js';
|
import { createStateRoute } from './http/routes/stateRoute.js';
|
||||||
import { sendError } from './http/response.js';
|
import { sendError } from './http/response.js';
|
||||||
@@ -312,6 +313,9 @@ const deviceInventory = settings.appMode === 'gateway'
|
|||||||
const localConnectivityDiagnostics = !remoteDataplane
|
const localConnectivityDiagnostics = !remoteDataplane
|
||||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
: null;
|
: null;
|
||||||
|
const localDnsDiagnostics = !remoteDataplane
|
||||||
|
? createDnsDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
|
: null;
|
||||||
const localFailoverDiagnostics = !remoteDataplane ? {
|
const localFailoverDiagnostics = !remoteDataplane ? {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
@@ -503,6 +507,23 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
|||||||
services,
|
services,
|
||||||
target,
|
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({
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
diagnostics: connectivityDiagnostics,
|
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,
|
type NormalizedServer,
|
||||||
} from '../../shared/serverIdentity.js';
|
} from '../../shared/serverIdentity.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 9;
|
export const STATE_SCHEMA_VERSION = 10;
|
||||||
|
|
||||||
export interface AtomicWriteOptions {
|
export interface AtomicWriteOptions {
|
||||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
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_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 {
|
export interface DiagnosticService {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,10 +65,43 @@ export interface DiagnosticService {
|
|||||||
url: string;
|
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 {
|
export interface DiagnosticSettings {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
customServices: DiagnosticService[];
|
customServices: DiagnosticService[];
|
||||||
hiddenServiceIds: string[];
|
hiddenServiceIds: string[];
|
||||||
|
customDnsResolvers: CustomDnsResolver[];
|
||||||
|
customDnsDomains: CustomDnsDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
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(
|
export function normalizeDiagnosticSettings(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
{ strict = false }: { strict?: boolean } = {},
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
@@ -78,17 +204,35 @@ export function normalizeDiagnosticSettings(
|
|||||||
const hiddenServiceIds = requestedHiddenIds
|
const hiddenServiceIds = requestedHiddenIds
|
||||||
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
||||||
.filter((id, index, ids) => ids.indexOf(id) === index);
|
.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 && (
|
if (strict && (
|
||||||
typeof candidate.configured !== 'boolean'
|
typeof candidate.configured !== 'boolean'
|
||||||
|| !Array.isArray(candidate.customServices)
|
|| !Array.isArray(candidate.customServices)
|
||||||
|| !Array.isArray(candidate.hiddenServiceIds)
|
|| !Array.isArray(candidate.hiddenServiceIds)
|
||||||
|| customServices.length !== requestedServices.length
|
|| customServices.length !== requestedServices.length
|
||||||
|| hiddenServiceIds.length !== requestedHiddenIds.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');
|
)) throw new TypeError('Invalid diagnostic settings');
|
||||||
return {
|
return {
|
||||||
configured: candidate.configured === true,
|
configured: candidate.configured === true,
|
||||||
customServices,
|
customServices,
|
||||||
hiddenServiceIds,
|
hiddenServiceIds,
|
||||||
|
customDnsResolvers,
|
||||||
|
customDnsDomains,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.35.1',
|
macClient: '0.36.0',
|
||||||
gatewayClient: '0.37.1',
|
gatewayClient: '0.38.0',
|
||||||
gatewayBackend: '0.37.0',
|
gatewayBackend: '0.38.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const componentActions = {
|
|||||||
setDevicePolicy: api.devices.setPolicy,
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
pingServers: api.servers.ping,
|
pingServers: api.servers.ping,
|
||||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||||
|
loadDnsDiagnosticsCatalog: api.diagnostics.dnsCatalog,
|
||||||
|
runDnsDiagnostics: api.diagnostics.dns,
|
||||||
loadActivityJournal: api.activityJournal.page,
|
loadActivityJournal: api.activityJournal.page,
|
||||||
loadLiveTraffic: api.traffic.live,
|
loadLiveTraffic: api.traffic.live,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,6 +202,14 @@ export const api = {
|
|||||||
body: JSON.stringify({ target }),
|
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(
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
'/api/diagnostics/settings',
|
'/api/diagnostics/settings',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ interface ComponentActions {
|
|||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||||
loadLiveTraffic: () => Promise<unknown>;
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -968,6 +970,8 @@ export function ClientOverviewPage({
|
|||||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||||
feature={diagnosticsFeature}
|
feature={diagnosticsFeature}
|
||||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||||
|
loadDnsDiagnosticsCatalog={actions.loadDnsDiagnosticsCatalog}
|
||||||
|
runDnsDiagnostics={actions.runDnsDiagnostics}
|
||||||
settings={state.diagnostics}
|
settings={state.diagnostics}
|
||||||
updateSettings={onUpdateDiagnosticsSettings}
|
updateSettings={onUpdateDiagnosticsSettings}
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from './connectivityResult.js';
|
} from './connectivityResult.js';
|
||||||
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
||||||
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
||||||
|
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.js';
|
||||||
|
|
||||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||||
@@ -36,7 +37,7 @@ interface IpSourceDefinition {
|
|||||||
|
|
||||||
type StatusValue = [className: string, label: string];
|
type StatusValue = [className: string, label: string];
|
||||||
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
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> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -236,12 +237,16 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
|||||||
export function ConnectivityDiagnosticsPanel({
|
export function ConnectivityDiagnosticsPanel({
|
||||||
feature,
|
feature,
|
||||||
runConnectivityDiagnostics,
|
runConnectivityDiagnostics,
|
||||||
|
loadDnsDiagnosticsCatalog,
|
||||||
|
runDnsDiagnostics,
|
||||||
settings,
|
settings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
isGateway,
|
isGateway,
|
||||||
}: {
|
}: {
|
||||||
feature: DiagnosticsFeature;
|
feature: DiagnosticsFeature;
|
||||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
settings: DiagnosticSettings;
|
settings: DiagnosticSettings;
|
||||||
updateSettings: UpdateSettings;
|
updateSettings: UpdateSettings;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
@@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DnsDiagnosticsSection
|
||||||
|
open={open}
|
||||||
|
settings={settings}
|
||||||
|
updateSettings={updateSettings}
|
||||||
|
loadCatalog={loadDnsDiagnosticsCatalog}
|
||||||
|
runDiagnostics={runDnsDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
</Drawer>
|
</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;
|
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) {
|
@media (max-width: 560px) {
|
||||||
.client-diagnostics-table th,
|
.client-diagnostics-table th,
|
||||||
.client-diagnostics-table td {
|
.client-diagnostics-table td {
|
||||||
@@ -461,4 +660,57 @@
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,15 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
|||||||
test('diagnostics settings validate and replace one canonical revision', () => {
|
test('diagnostics settings validate and replace one canonical revision', () => {
|
||||||
let state = {
|
let state = {
|
||||||
revision: 7,
|
revision: 7,
|
||||||
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
diagnostics: {
|
||||||
|
configured: false,
|
||||||
|
customServices: [],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const useCase = createConnectivityDiagnosticsUseCase({
|
const useCase = createConnectivityDiagnosticsUseCase({
|
||||||
state: {
|
state: {
|
||||||
@@ -212,6 +220,10 @@ test('diagnostics settings validate and replace one canonical revision', () => {
|
|||||||
configured: true,
|
configured: true,
|
||||||
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||||
hiddenServiceIds: ['speedtest'],
|
hiddenServiceIds: ['speedtest'],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
});
|
});
|
||||||
assert.equal(state.revision, 8);
|
assert.equal(state.revision, 8);
|
||||||
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
||||||
@@ -245,6 +257,11 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
calls.push(args);
|
calls.push(args);
|
||||||
return { checkedAt: 'now', vpn: { server: null } };
|
return { checkedAt: 'now', vpn: { server: null } };
|
||||||
},
|
},
|
||||||
|
dnsCatalog: async () => ({ resolvers: [{ id: 'google-dns' }], domains: [] }),
|
||||||
|
runDns: async (...args) => {
|
||||||
|
calls.push(['dns', ...args]);
|
||||||
|
return { checkedAt: 'dns-now', results: [] };
|
||||||
|
},
|
||||||
updateSettings: (...args) => calls.push(['settings', ...args]),
|
updateSettings: (...args) => calls.push(['settings', ...args]),
|
||||||
},
|
},
|
||||||
readBody: async () => {
|
readBody: async () => {
|
||||||
@@ -276,6 +293,16 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
||||||
assert.equal(settingsResponse.payload.success, true);
|
assert.equal(settingsResponse.payload.success, true);
|
||||||
|
|
||||||
|
const catalogResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/diagnostics/dns' }, catalogResponse), true);
|
||||||
|
assert.deepEqual(catalogResponse.payload, { resolvers: [{ id: 'google-dns' }], domains: [] });
|
||||||
|
|
||||||
|
body = { domainId: 'youtube', resolverId: 'google-dns' };
|
||||||
|
const dnsResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'POST', url: '/api/diagnostics/dns' }, dnsResponse), true);
|
||||||
|
assert.deepEqual(calls.at(-1), ['dns', 'youtube', 'google-dns']);
|
||||||
|
assert.deepEqual(dnsResponse.payload, { checkedAt: 'dns-now', results: [] });
|
||||||
|
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['GET', '/api/diagnostics/connectivity'],
|
['GET', '/api/diagnostics/connectivity'],
|
||||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||||
@@ -284,7 +311,7 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
]) {
|
]) {
|
||||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||||
}
|
}
|
||||||
assert.equal(bodyReads, 3);
|
assert.equal(bodyReads, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
[{ id: 'custom-test', url: 'https://example.com' }],
|
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
'site:custom-test',
|
'site:custom-test',
|
||||||
);
|
);
|
||||||
|
await client.getDnsDiagnosticsCatalog([{ id: 'dns' }], [{ id: 'domain' }]);
|
||||||
|
await client.runDnsDiagnostics([{ id: 'dns' }], [{ id: 'domain' }], 'youtube', 'google-dns');
|
||||||
await client.checkConfig({ outbounds: [] });
|
await client.checkConfig({ outbounds: [] });
|
||||||
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
||||||
await client.readFailoverSelector();
|
await client.readFailoverSelector();
|
||||||
@@ -53,6 +55,8 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'GET /device-policy /run/dataplane.sock',
|
'GET /device-policy /run/dataplane.sock',
|
||||||
'PUT /device-policy /run/dataplane.sock',
|
'PUT /device-policy /run/dataplane.sock',
|
||||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns/catalog /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns /run/dataplane.sock',
|
||||||
'POST /config/check /run/dataplane.sock',
|
'POST /config/check /run/dataplane.sock',
|
||||||
'POST /failover/probe /run/dataplane.sock',
|
'POST /failover/probe /run/dataplane.sock',
|
||||||
'GET /failover/selector /run/dataplane.sock',
|
'GET /failover/selector /run/dataplane.sock',
|
||||||
@@ -68,10 +72,18 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
target: 'site:custom-test',
|
target: 'site:custom-test',
|
||||||
});
|
});
|
||||||
assert.equal(requests[8].timeoutMs, 25_000);
|
assert.equal(requests[8].timeoutMs, 25_000);
|
||||||
assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
|
assert.deepEqual(requests[9].body, {
|
||||||
assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
assert.equal(requests[10].timeoutMs, 19_000);
|
});
|
||||||
assert.deepEqual(requests[12].body, { role: 'reserve' });
|
assert.deepEqual(requests[10].body, {
|
||||||
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
|
domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(requests[10].timeoutMs, 40_000);
|
||||||
|
assert.deepEqual(requests[11].body, { config: { outbounds: [] } });
|
||||||
|
assert.deepEqual(requests[12].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||||
|
assert.equal(requests[12].timeoutMs, 19_000);
|
||||||
|
assert.deepEqual(requests[14].body, { role: 'reserve' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDnsQuery,
|
||||||
|
createDnsDiagnosticsService,
|
||||||
|
parseDnsResponse,
|
||||||
|
} from '../../dist/server/services/dnsDiagnosticsService.js';
|
||||||
|
|
||||||
|
function wireResponse(query, { rcode = 0, truncated = false, address = null } = {}) {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
query.copy(header, 0, 0, 2);
|
||||||
|
header.writeUInt16BE(0x8180 | rcode | (truncated ? 0x0200 : 0), 2);
|
||||||
|
header.writeUInt16BE(1, 4);
|
||||||
|
header.writeUInt16BE(address ? 1 : 0, 6);
|
||||||
|
const question = query.subarray(12);
|
||||||
|
if (!address) return Buffer.concat([header, question]);
|
||||||
|
const type = query.readUInt16BE(query.length - 4);
|
||||||
|
const data = type === 1
|
||||||
|
? Buffer.from(address.split('.').map(Number))
|
||||||
|
: Buffer.from(address.split(':').flatMap((part) => {
|
||||||
|
const value = Number.parseInt(part || '0', 16);
|
||||||
|
return [value >> 8, value & 0xff];
|
||||||
|
}));
|
||||||
|
const answer = Buffer.alloc(12);
|
||||||
|
answer.writeUInt16BE(0xc00c, 0);
|
||||||
|
answer.writeUInt16BE(type, 2);
|
||||||
|
answer.writeUInt16BE(1, 4);
|
||||||
|
answer.writeUInt32BE(60, 6);
|
||||||
|
answer.writeUInt16BE(data.length, 10);
|
||||||
|
return Buffer.concat([header, question, answer, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressFor(packet, path = 'direct') {
|
||||||
|
return packet.readUInt16BE(packet.length - 4) === 1
|
||||||
|
? path === 'direct' ? '8.8.8.8' : '9.9.9.9'
|
||||||
|
: path === 'direct' ? '2001:db8:0:0:0:0:0:1' : '2001:db8:0:0:0:0:0:2';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('DNS wire codec parses compressed A/AAAA and rejects malformed packets', () => {
|
||||||
|
const aQuery = buildDnsQuery('www.youtube.com', 'A', 17);
|
||||||
|
const aaaaQuery = buildDnsQuery('www.youtube.com', 'AAAA', 18);
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aQuery, { address: '8.8.8.8' }), 17), {
|
||||||
|
rcode: 'NOERROR', truncated: false, ipv4: ['8.8.8.8'], ipv6: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aaaaQuery, {
|
||||||
|
address: '2001:db8:0:0:0:0:0:1',
|
||||||
|
}), 18).ipv6, ['2001:db8:0:0:0:0:0:1']);
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 3 }), 17).rcode, 'NXDOMAIN');
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 2 }), 17).rcode, 'SERVFAIL');
|
||||||
|
assert.throws(() => parseDnsResponse(Buffer.from([0, 1]), 1), /malformed/);
|
||||||
|
assert.throws(() => parseDnsResponse(wireResponse(aQuery), 99), /unexpected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker keeps Direct and VPN independent and never probes VPN when it is off', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const classicExchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
calls.push([route, resolver.endpoint, packet.readUInt16BE(packet.length - 4)]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'udp',
|
||||||
|
latencyMs: route === 'direct' ? 10 : 20,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080, getServers: () => [], classicExchange,
|
||||||
|
now: () => '2026-09-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
const running = await service.run({ vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route).sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(running.results[0].comparison, 'different');
|
||||||
|
assert.deepEqual(running.results[0].direct.ipv4, ['8.8.8.8']);
|
||||||
|
assert.deepEqual(running.results[0].vpn.ipv6, ['2001:db8:0:0:0:0:0:2']);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
const stopped = await service.run({ vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route), ['direct', 'direct']);
|
||||||
|
assert.equal(stopped.results[0].vpn.status, 'vpn-off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker retries timeout once and uses TCP fallback on a truncated response', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const timedOut = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
const error = new Error('timeout');
|
||||||
|
error.code = 'ETIMEDOUT';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timeoutResult = await timedOut.run({
|
||||||
|
vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(attempts, 4);
|
||||||
|
assert.equal(timeoutResult.results[0].direct.status, 'timeout');
|
||||||
|
|
||||||
|
const fallbackCalls = [];
|
||||||
|
const fallback = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async ({ packet }) => ({
|
||||||
|
response: wireResponse(packet, { truncated: true }), transport: 'udp', latencyMs: 2,
|
||||||
|
}),
|
||||||
|
tcpExchange: async ({ path: route, packet }) => {
|
||||||
|
fallbackCalls.push(route);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'tcp', latencyMs: 4,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fallbackResult = await fallback.run({
|
||||||
|
vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.deepEqual(fallbackCalls.sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(fallbackResult.results[0].direct.transport, 'tcp');
|
||||||
|
assert.equal(fallbackResult.results[0].vpn.transport, 'tcp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom DoH is pinned only to public bootstrap addresses', async () => {
|
||||||
|
const resolver = {
|
||||||
|
id: 'custom-dns-office', label: 'Office DoH', kind: 'doh', endpoint: 'https://dns.example/dns-query',
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
|
||||||
|
dohExchange: async ({ path: route, resolver: pinned, packet }) => {
|
||||||
|
calls.push([route, pinned.bootstrap]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'doh', latencyMs: 7,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['direct', '93.184.216.34'], ['direct', '93.184.216.34'],
|
||||||
|
['vpn', '93.184.216.34'], ['vpn', '93.184.216.34'],
|
||||||
|
]);
|
||||||
|
assert.equal(result.results[0].direct.transport, 'doh');
|
||||||
|
|
||||||
|
let requests = 0;
|
||||||
|
const blocked = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||||
|
dohExchange: async () => { requests += 1; throw new Error('unexpected'); },
|
||||||
|
});
|
||||||
|
const blockedResult = await blocked.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.equal(requests, 0);
|
||||||
|
assert.equal(blockedResult.results[0].direct.error, 'DoH endpoint is not public');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full DNS run includes system and built-in rows with at most three resolver workers', async () => {
|
||||||
|
const active = new Map();
|
||||||
|
let maximumResolvers = 0;
|
||||||
|
const exchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
active.set(resolver.id, (active.get(resolver.id) || 0) + 1);
|
||||||
|
maximumResolvers = Math.max(maximumResolvers, active.size);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
const count = active.get(resolver.id) - 1;
|
||||||
|
if (count) active.set(resolver.id, count);
|
||||||
|
else active.delete(resolver.id);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: resolver.kind === 'doh' ? 'doh' : 'udp',
|
||||||
|
latencyMs: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => ['192.168.1.1'],
|
||||||
|
classicExchange: exchange,
|
||||||
|
dohExchange: exchange,
|
||||||
|
});
|
||||||
|
const catalog = await service.catalog();
|
||||||
|
assert.equal(catalog.resolvers[0].system, true);
|
||||||
|
assert.equal(catalog.resolvers.length, 7);
|
||||||
|
assert.deepEqual(catalog.domains.map(({ hostname }) => hostname), ['www.youtube.com', 'chatgpt.com']);
|
||||||
|
|
||||||
|
const result = await service.run({ vpnAvailable: false, domainId: 'youtube' });
|
||||||
|
assert.equal(result.results.length, 7);
|
||||||
|
assert.ok(maximumResolvers <= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default transports enforce direct bypass, VPN proxy, binary DoH and no redirects', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../src/server/services/dnsDiagnosticsService.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(source, /path === 'direct'[\s\S]*udpExchange\(address, port/);
|
||||||
|
assert.match(source, /socksHandshake\(3,[\s\S]*socksHandshake\(1,/);
|
||||||
|
assert.match(source, /'--noproxy', '\*'/);
|
||||||
|
assert.match(source, /'--proxy', `http:\/\/127\.0\.0\.1:\$\{proxyPort\}`/);
|
||||||
|
assert.match(source, /'--data-binary', '@-'/);
|
||||||
|
assert.match(source, /'--resolve'/);
|
||||||
|
assert.doesNotMatch(source, /--location/);
|
||||||
|
});
|
||||||
@@ -102,6 +102,8 @@ test('schema v5 migrates rules and diagnostics settings with an exact backup', (
|
|||||||
configured: false,
|
configured: false,
|
||||||
customServices: [],
|
customServices: [],
|
||||||
hiddenServiceIds: [],
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [],
|
||||||
|
customDnsDomains: [],
|
||||||
});
|
});
|
||||||
assert.equal(migrated.routeRulesRevision, 7);
|
assert.equal(migrated.routeRulesRevision, 7);
|
||||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||||
@@ -149,6 +151,35 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
|||||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schema v9 adds empty DNS settings atomically and preserves the exact backup', (t) => {
|
||||||
|
const filePath = fixture(t);
|
||||||
|
const legacy = {
|
||||||
|
schemaVersion: 9,
|
||||||
|
revision: 23,
|
||||||
|
routeRules: [],
|
||||||
|
appliedRouteRules: [],
|
||||||
|
diagnostics: {
|
||||||
|
configured: true,
|
||||||
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/' }],
|
||||||
|
hiddenServiceIds: ['youtube'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bytes = JSON.stringify(legacy);
|
||||||
|
fs.writeFileSync(filePath, bytes);
|
||||||
|
|
||||||
|
const store = createStateStore(filePath, {
|
||||||
|
now: () => new Date('2026-09-01T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const migrated = store.read();
|
||||||
|
|
||||||
|
assert.equal(migrated.schemaVersion, 10);
|
||||||
|
assert.equal(migrated.revision, 23);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsResolvers, []);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsDomains, []);
|
||||||
|
assert.equal(store.migration.fromVersion, 9);
|
||||||
|
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||||
|
});
|
||||||
|
|
||||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||||
for (const [name, rule] of [
|
for (const [name, rule] of [
|
||||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/Conn
|
|||||||
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
||||||
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
|
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
|
||||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
||||||
|
const dns = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8');
|
||||||
|
|
||||||
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
||||||
const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 };
|
const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 };
|
||||||
@@ -122,3 +123,19 @@ test('network identity is one stable compact row for Direct and VPN', () => {
|
|||||||
assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/);
|
assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/);
|
||||||
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DNS diagnostics reuse the drawer owner, canonical settings and independent row refresh', () => {
|
||||||
|
assert.match(panel, /<DnsDiagnosticsSection[\s\S]*settings=\{settings\}[\s\S]*updateSettings=\{updateSettings\}/);
|
||||||
|
assert.match(panel, /<DnsDiagnosticsSection[\s\S]*loadCatalog=\{loadDnsDiagnosticsCatalog\}[\s\S]*runDiagnostics=\{runDnsDiagnostics\}/);
|
||||||
|
assert.match(dns, /<select[\s\S]*id="client-dns-domain"[\s\S]*value=\{activeDomainId\}/);
|
||||||
|
assert.match(dns, /setCache\(\(current\)[\s\S]*mergeRow\(previous\[row\.resolver\.id\], row\)/);
|
||||||
|
assert.match(dns, /function preserveAddresses[\s\S]*ipv4: previous\.ipv4, ipv6: previous\.ipv6/);
|
||||||
|
assert.match(dns, /aria-busy=\{running\}/);
|
||||||
|
assert.match(dns, /<Refresh label=\{`Проверить: \$\{item\.label\}`\}[\s\S]*onClick=\{\(\) => void run\(item\.id\)\}/);
|
||||||
|
assert.match(dns, /updateSettings\([\s\S]*customDnsDomains/);
|
||||||
|
assert.match(dns, /updateSettings\([\s\S]*customDnsResolvers/);
|
||||||
|
assert.match(dns, /settings\.customDnsDomains\.length >= MAX_CUSTOM_DNS_DOMAINS/);
|
||||||
|
assert.match(dns, /settings\.customDnsResolvers\.length >= MAX_CUSTOM_DNS_RESOLVERS/);
|
||||||
|
assert.match(dns, /<b>A<\/b>[\s\S]*<b>AAAA<\/b>/);
|
||||||
|
assert.match(dns, /Разные ответы могут быть нормой для CDN/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/Routin
|
|||||||
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||||||
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
||||||
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
||||||
|
const dnsDiagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8');
|
||||||
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||||
|
|
||||||
function rule(selector, source = styles) {
|
function rule(selector, source = styles) {
|
||||||
@@ -264,6 +265,15 @@ test('responsive motion has a reduced-motion fallback', () => {
|
|||||||
assert.doesNotMatch(styles, /\.client-rail-action:active:not\(:disabled\) svg/);
|
assert.doesNotMatch(styles, /\.client-rail-action:active:not\(:disabled\) svg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DNS keeps three result columns while its add forms stack on narrow screens', () => {
|
||||||
|
assert.match(styles, /\.client-dns-row \{[\s\S]*min-height:\s*92px/);
|
||||||
|
assert.match(styles, /\.client-dns-result \{[\s\S]*min-height:\s*72px/);
|
||||||
|
assert.match(styles, /\.client-dns-header,\s*\.client-dns-row \{[\s\S]*grid-template-columns:\s*35% minmax\(0, 1fr\) minmax\(0, 1fr\) 28px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-inline-form \{[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\) 44px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-row \{[\s\S]*grid-template-columns:\s*32% minmax\(0, 1fr\) minmax\(0, 1fr\) 44px/);
|
||||||
|
assert.match(dnsDiagnostics, /role="table" aria-busy=\{running\}/);
|
||||||
|
});
|
||||||
|
|
||||||
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
|
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
|
||||||
const tooltip = rule('.client-tooltip');
|
const tooltip = rule('.client-tooltip');
|
||||||
|
|
||||||
|
|||||||
@@ -40,26 +40,26 @@ const expectedImports = [
|
|||||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const acceptedLedger = {
|
const acceptedLedger = {
|
||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 1163,
|
cascadeEdges: 1188,
|
||||||
customProperties: 115,
|
customProperties: 115,
|
||||||
declarations: 4746,
|
declarations: 4862,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 55,
|
keyframes: 55,
|
||||||
media: 23,
|
media: 23,
|
||||||
rules: 1288,
|
rules: 1329,
|
||||||
variableReferences: 1237,
|
variableReferences: 1270,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: 'c1b3eaa3d0ad08e3393cf90878f90e17a5401d79eed9072d06f39b7841d8bbfd',
|
cascadeEdges: '3d02c10ab8fd95f7eed306048e9b59ec391a0febaa9e3e235badcff3b042eef7',
|
||||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||||
declarations: 'e668191e548093c02463e57b81eb6c12b826991a5e10f5191d87b73e682cd852',
|
declarations: '0f31e2cdeeab02b9e0a845a6b50ff7e3f035a441217a418e7b5465b5a296ae16',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
||||||
ruleDeclarationSequences: '2cc97550fecd8bbf46217dc378cc490fd41d977abcdcc4c93a18c7593ba42f53',
|
ruleDeclarationSequences: '33a0dff2428a8e712776d40d796ea05d169544eb1af02ad350759061fb2a3c3c',
|
||||||
selectors: 'c7f4752d81bbeb04612eb6c79a42bcde875c762d81591b2cdf2fb2bbd7399ea2',
|
selectors: '5734c69a504c59025e2e6b1637cfb6d1905ca1ae358783d0916a694a973eea22',
|
||||||
variableReferences: '69c03c059f9ce107bc8be0397cff90e1b8b3749b8e37ba8e231e2d6cdcd61e46',
|
variableReferences: '49ad724b2812128f5344fdd55b177aa96ea9b30eb4d878dc86f2e4f3fa898182',
|
||||||
witnesses: '99713805eb7a5c67427ace32775f0cc503b3b2ac63240db717e71b36d09ddd68',
|
witnesses: '4e357ee4f7310d60399baa3099bd8f4ed320debc1e3c9a09f55b892a1178049c',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 1312);
|
assert.equal(witnesses.length, 1375);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -221,11 +221,7 @@ test('accepted stylesheet has pinned declaration, selector, keyframe, variable,
|
|||||||
|
|
||||||
test('every live production selector has an expanded DOM witness', () => {
|
test('every live production selector has an expanded DOM witness', () => {
|
||||||
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
||||||
assert.deepEqual(unmatched, [
|
assert.deepEqual(unmatched, []);
|
||||||
'.client-diagnostics-section-title button',
|
|
||||||
'.client-diagnostics-section-title button:disabled',
|
|
||||||
'.client-diagnostics-section-title button:focus-visible',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
||||||
@@ -409,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-BLRRtFYh.css']);
|
assert.deepEqual(assets, ['index-fllV-PfI.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 180634);
|
assert.equal(built.byteLength, 184958);
|
||||||
assert.equal(sha256(built), '6bf3cb9c724758de39567c606c939e6041edde82837f826e8d2d4e31ebd1d8a8');
|
assert.equal(sha256(built), '3a9ed6fdf6c5d81234de9aa0abd995bc9db1cfe298194162654edcbfe3ae7c62');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
|||||||
.map(({ source }) => source)
|
.map(({ source }) => source)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 16);
|
assert.equal((production.match(/<Tooltip\b/g) || []).length, 17);
|
||||||
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
||||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
|
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
|
||||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
|
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
|
||||||
|
|||||||
Reference in New Issue
Block a user