Files
harbor-net/test/server/connectivity-diagnostics.test.js
dokril 76a99f098a
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s
Add DNS diagnostics across dataplane and client
2026-09-01 04:16:15 +03:00

551 lines
21 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
createConnectivityDiagnosticsService,
CURL_META_MARKER,
} from '../../dist/server/services/connectivityDiagnosticsService.js';
import { createConnectivityDiagnosticsUseCase } from '../../dist/server/features/diagnostics/index.js';
import { createConnectivityDiagnosticsRoute } from '../../dist/server/http/routes/connectivityDiagnosticsRoute.js';
const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.ts'), 'utf8');
function response(body = '', overrides = {}) {
return {
exitCode: 0,
error: '',
stderr: '',
stdout: `${body}${CURL_META_MARKER}${JSON.stringify({
exitcode: 0,
http_code: 204,
time_starttransfer: 0.12,
time_total: 0.15,
...overrides,
})}`,
};
}
test('connectivity diagnostics force separate direct and VPN paths', async () => {
const calls = [];
const execute = async (args) => {
calls.push(args);
const vpn = args.includes('--proxy');
const url = args.at(-1);
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
if (url.includes('api6')) return response(vpn ? '2001:db8::20' : '2001:db8::10');
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
return response();
};
const service = createConnectivityDiagnosticsService({
proxyPort: 18080,
execute,
now: () => '2026-08-07T12:00:00.000Z',
});
const result = await service.run({ vpnAvailable: true });
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
assert.deepEqual(result.vpn.ipv4.addresses, ['203.0.113.20']);
assert.equal(result.direct.ipv6, '2001:db8::10');
assert.equal(result.vpn.ipv6, '2001:db8::20');
assert.equal(result.direct.network.error, 'invalid network response');
assert.equal(result.assessment.summary, 'available');
assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*')));
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
});
test('connectivity diagnostics endpoint is available in Connect and Gateway through one owner', () => {
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
assert.match(server, /sendState: \(res\) => stateRoute\.send\(res\)/);
assert.match(server, /createConnectivityDiagnosticsUseCase\(\{/);
assert.match(server, /createConnectivityDiagnosticsRoute\(\{/);
assert.match(server, /connectivityDiagnosticsRoute\.handle\(req, res\)/);
assert.doesNotMatch(server, /['"]\/api\/diagnostics\/connectivity['"]/);
assert.doesNotMatch(server, /appliedServerId\s*\|\|\s*state\.selectedServerId/);
});
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
const events = [];
let state = {
revision: 3,
diagnostics: {
configured: true,
customServices: [{ id: 'custom-saved', label: 'Saved', url: 'https://example.com/' }],
hiddenServiceIds: [],
},
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'applied',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [
{ id: 'applied', label: 'Applied before probe' },
{ id: 'selected', label: 'Selected' },
],
}],
};
let releaseProbe;
const probe = new Promise((resolve) => { releaseProbe = resolve; });
const sourceResult = {
checkedAt: 'now',
direct: { available: true },
vpn: { available: true, server: { id: 'stale', label: 'Stale' }, detail: 7 },
assessment: { summary: 'available' },
};
const useCase = createConnectivityDiagnosticsUseCase({
state: {
read: () => {
events.push('state');
return state;
},
update: () => { throw new Error('unexpected update'); },
},
runDiagnostics: async (services, target) => {
events.push(['probe', services, target]);
await probe;
return sourceResult;
},
});
const resultPromise = useCase.run(42);
state.profiles[0].servers[0].label = 'Changed during probe';
releaseProbe();
const result = await resultPromise;
assert.deepEqual(events, ['state', ['probe', state.diagnostics.customServices, 42]]);
assert.deepEqual(result, {
...sourceResult,
vpn: {
...sourceResult.vpn,
server: { id: 'applied', label: 'Applied before probe' },
},
});
assert.deepEqual(sourceResult.vpn.server, { id: 'stale', label: 'Stale' });
});
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
const result = { vpn: { available: false }, marker: true };
const selected = createConnectivityDiagnosticsUseCase({
state: {
read: () => ({
desiredProfileId: 'primary',
appliedServerId: '',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
update: () => { throw new Error('unexpected update'); },
},
runDiagnostics: async () => result,
});
assert.deepEqual((await selected.run(null)).vpn.server, {
id: 'selected',
label: 'Selected',
});
const missingApplied = createConnectivityDiagnosticsUseCase({
state: {
read: () => ({
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'missing',
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
update: () => { throw new Error('unexpected update'); },
},
runDiagnostics: async () => result,
});
assert.equal((await missingApplied.run(null)).vpn.server, null);
const stateError = new Error('state failed');
let probes = 0;
const brokenState = createConnectivityDiagnosticsUseCase({
state: {
read: () => { throw stateError; },
update: () => { throw new Error('unexpected update'); },
},
runDiagnostics: async () => { probes += 1; return result; },
});
await assert.rejects(brokenState.run(null), (error) => error === stateError);
assert.equal(probes, 0);
const probeError = new Error('probe failed');
const brokenProbe = createConnectivityDiagnosticsUseCase({
state: {
read: () => ({ profiles: [] }),
update: () => { throw new Error('unexpected update'); },
},
runDiagnostics: async () => { throw probeError; },
});
await assert.rejects(brokenProbe.run(null), (error) => error === probeError);
});
test('diagnostics settings validate and replace one canonical revision', () => {
let state = {
revision: 7,
diagnostics: {
configured: false,
customServices: [],
hiddenServiceIds: [],
customDnsResolvers: [{
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
}],
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
},
};
const useCase = createConnectivityDiagnosticsUseCase({
state: {
read: () => state,
update: (mutator) => {
state = { ...mutator(state), revision: state.revision + 1 };
return state;
},
},
runDiagnostics: async () => ({ vpn: {} }),
});
useCase.updateSettings({
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
hiddenServiceIds: ['speedtest'],
}, 7);
assert.deepEqual(state.diagnostics, {
configured: true,
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
hiddenServiceIds: ['speedtest'],
customDnsResolvers: [{
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
}],
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
});
assert.equal(state.revision, 8);
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
code: 'STATE_CONFLICT',
});
assert.throws(() => useCase.updateSettings({
customServices: [{ id: 'bad', label: 'Router', url: 'http://router.local/' }],
hiddenServiceIds: [],
}, 8), { code: 'REQUEST_INVALID' });
});
function routeResponse() {
return {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(payload) {
this.payload = JSON.parse(payload);
},
};
}
test('connectivity route preserves exact URL, defaults and raw response', async () => {
const calls = [];
let body = {};
let bodyReads = 0;
const route = createConnectivityDiagnosticsRoute({
diagnostics: {
run: async (...args) => {
calls.push(args);
return { checkedAt: 'now', vpn: { server: null } };
},
dnsCatalog: async () => ({ resolvers: [{ id: 'google-dns' }], domains: [] }),
runDns: async (...args) => {
calls.push(['dns', ...args]);
return { checkedAt: 'dns-now', results: [] };
},
updateSettings: (...args) => calls.push(['settings', ...args]),
},
readBody: async () => {
bodyReads += 1;
return body;
},
sendState: async (response) => {
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({ success: true, state: {} }));
},
});
const res = routeResponse();
assert.equal(await route.handle({
method: 'POST',
url: '/api/diagnostics/connectivity',
}, res), true);
assert.deepEqual(calls, [[null]]);
assert.equal(res.status, 200);
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
assert.deepEqual(res.payload, { checkedAt: 'now', vpn: { server: null } });
body = { services: null, target: 17 };
await route.handle({ method: 'POST', url: '/api/diagnostics/connectivity' }, routeResponse());
assert.deepEqual(calls.at(-1), [17]);
body = { settings: { customServices: [], hiddenServiceIds: [] }, expectedRevision: 4 };
const settingsResponse = routeResponse();
assert.equal(await route.handle({ method: 'PUT', url: '/api/diagnostics/settings' }, settingsResponse), true);
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
assert.equal(settingsResponse.payload.success, true);
const catalogResponse = routeResponse();
assert.equal(await route.handle({ method: 'GET', url: '/api/diagnostics/dns' }, catalogResponse), true);
assert.deepEqual(catalogResponse.payload, { resolvers: [{ id: 'google-dns' }], domains: [] });
body = { domainId: 'youtube', resolverId: 'google-dns' };
const dnsResponse = routeResponse();
assert.equal(await route.handle({ method: 'POST', url: '/api/diagnostics/dns' }, dnsResponse), true);
assert.deepEqual(calls.at(-1), ['dns', 'youtube', 'google-dns']);
assert.deepEqual(dnsResponse.payload, { checkedAt: 'dns-now', results: [] });
for (const [method, url] of [
['GET', '/api/diagnostics/connectivity'],
['POST', '/api/diagnostics/connectivity?target=all'],
['POST', '/api/diagnostics/settings'],
['POST', '/api/diagnostics/other'],
]) {
assert.equal(await route.handle({ method, url }, routeResponse()), false);
}
assert.equal(bodyReads, 4);
});
test('a targeted IP row uses three samples and keeps the majority address', async () => {
const attempts = { direct: 0, vpn: 0 };
const execute = async (args) => {
const route = args.includes('--proxy') ? 'vpn' : 'direct';
attempts[route] += 1;
if (route === 'vpn' && attempts.vpn === 1) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
const address = route === 'vpn' ? '203.0.113.20' : '198.51.100.10';
return response(`{"ipv4":"${address}"}`, {
time_starttransfer: route === 'vpn' ? attempts.vpn * 0.1 : attempts.direct * 0.1,
});
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: true, target: 'ip:yandex-internet' });
assert.equal(result.direct.ipv4.sources[0].attempts, 3);
assert.equal(result.vpn.ipv4.sources[0].attempts, 3);
assert.equal(result.direct.ipv4.sources[0].address, '198.51.100.10');
assert.equal(result.vpn.ipv4.sources[0].address, '203.0.113.20');
assert.equal(result.direct.ipv4.sources[0].latencyMs, 200);
assert.equal(result.vpn.ipv4.sources[0].latencyMs, 250);
});
test('a targeted network row reports provider, ASN and location through both forced paths', async () => {
const attempts = { direct: 0, vpn: 0 };
const calls = [];
const execute = async (args) => {
calls.push(args);
const route = args.includes('--proxy') ? 'vpn' : 'direct';
attempts[route] += 1;
if (route === 'direct' && attempts.direct === 1) return response('{invalid');
return response(JSON.stringify(route === 'vpn' ? {
success: true,
ip: '2a12:bec4:1bb0:10bb::2',
city: 'Amsterdam',
country_code: 'NL',
connection: { asn: 9009, isp: 'M247 Europe' },
} : {
success: true,
ip: '198.51.100.10',
city: 'Moscow',
country_code: 'RU',
connection: { asn: 12389, isp: 'Rostelecom' },
}));
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: true, target: 'network' });
assert.deepEqual(attempts, { direct: 3, vpn: 3 });
assert.deepEqual(result.direct.network, {
address: '198.51.100.10',
asn: 'AS12389',
provider: 'Rostelecom',
city: 'Moscow',
country: 'RU',
attempts: 3,
error: null,
});
assert.equal(result.vpn.network.address, '2a12:bec4:1bb0:10bb::2');
assert.equal(result.vpn.network.asn, 'AS9009');
assert.equal(result.vpn.network.provider, 'M247 Europe');
assert.ok(calls.every((args) => args.at(-1) === 'https://ipwho.is/' && args.includes('--ipv4')));
assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*')));
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
});
test('a targeted service row averages three measurements and ignores one transient failure', async () => {
const attempts = { direct: 0, vpn: 0 };
const execute = async (args) => {
const route = args.includes('--proxy') ? 'vpn' : 'direct';
attempts[route] += 1;
if (route === 'direct' && attempts.direct === 1) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
return response('', {
time_starttransfer: route === 'direct' ? attempts.direct * 0.1 : attempts.vpn * 0.2,
});
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: true, target: 'site:yandex' });
assert.deepEqual(attempts, { direct: 3, vpn: 3 });
assert.equal(result.direct.sites[0].status, 'available');
assert.equal(result.direct.sites[0].attempts, 3);
assert.equal(result.direct.sites[0].latencyMs, 250);
assert.equal(result.vpn.sites[0].latencyMs, 400);
});
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
const attempts = new Map();
const execute = async (args) => {
const vpn = args.includes('--proxy');
const url = args.at(-1);
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
const key = `${vpn}:${url}`;
attempts.set(key, (attempts.get(key) || 0) + 1);
if (!vpn && url.includes('youtube')) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: true });
assert.equal(result.assessment.summary, 'likely-direct-restriction');
assert.equal(
result.assessment.comparisons.find((item) => item.id === 'youtube').assessment,
'likely-direct-restriction',
);
assert.equal(attempts.get('false:https://www.youtube.com/generate_204'), 2);
});
test('connectivity diagnostics skips VPN probes when sing-box is off', async () => {
const calls = [];
const execute = async (args) => {
calls.push(args);
const url = args.at(-1);
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response('198.51.100.10');
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: false });
assert.equal(result.vpn.available, false);
assert.equal(result.assessment.summary, 'vpn-off');
assert.equal(calls.some((args) => args.includes('--proxy')), false);
});
test('connectivity diagnostics keeps every IP source in a partial snapshot', async () => {
const execute = async (args) => {
const url = args.at(-1);
if (url.includes('cloudflare')) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response('198.51.100.10');
if (url.includes('amazonaws')) return response('198.51.100.10');
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: false });
assert.equal(result.direct.internetAvailable, true);
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
assert.deepEqual(result.direct.ipv4.sources.map((source) => source.source), [
'cloudflare',
'ipify',
'aws',
'icanhazip',
'ifconfig-me',
'yandex-internet',
]);
});
test('connectivity diagnostics pins public custom services and rejects private destinations', async () => {
const calls = [];
const execute = async (args) => {
calls.push(args);
const url = args.at(-1);
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response('198.51.100.10');
return response();
};
const lookup = async (hostname) => hostname === 'router.local'
? [{ address: '192.168.50.1', family: 4 }]
: [{ address: '93.184.216.34', family: 4 }];
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute, lookup })
.run({
vpnAvailable: false,
services: [
{ id: 'custom-public', label: 'Example', url: 'https://example.com/status' },
{ id: 'custom-private', label: 'Router', url: 'https://router.local/' },
],
});
const publicCall = calls.find((args) => args.at(-1) === 'https://example.com/status');
assert.ok(publicCall);
assert.equal(publicCall.includes('--location'), false);
assert.deepEqual(publicCall.slice(publicCall.indexOf('--resolve'), publicCall.indexOf('--resolve') + 2), [
'--resolve',
'example.com:443:93.184.216.34',
]);
assert.equal(calls.some((args) => args.at(-1) === 'https://router.local/'), false);
assert.equal(result.direct.sites.find((site) => site.id === 'custom-private').stage, 'validation');
assert.equal(result.assessment.comparisons.some((item) => item.id === 'custom-public'), true);
});
test('a targeted custom row validates and samples only that service', async () => {
const lookups = [];
const calls = [];
const result = await createConnectivityDiagnosticsService({
proxyPort: 18080,
execute: async (args) => {
calls.push(args.at(-1));
return response();
},
lookup: async (hostname) => {
lookups.push(hostname);
return [{ address: '93.184.216.34', family: 4 }];
},
}).run({
vpnAvailable: false,
services: [
{ id: 'custom-first', url: 'https://first.example/' },
{ id: 'custom-second', url: 'https://second.example/' },
],
target: 'site:custom-second',
});
assert.deepEqual(lookups, ['second.example']);
assert.deepEqual(calls, ['https://second.example/', 'https://second.example/', 'https://second.example/']);
assert.equal(result.direct.sites[0].latencyMs, 120);
});
test('failover VPN checks apply their per-service timeout to curl', async () => {
const calls = [];
await createConnectivityDiagnosticsService({
proxyPort: 18080,
execute: async (args) => { calls.push(args); return response(); },
}).runVpn({ target: 'site:youtube', timeoutMs: 9_000 });
assert.equal(calls.length, 3);
for (const args of calls) {
assert.equal(args[args.indexOf('--max-time') + 1], '9');
assert.equal(args[args.indexOf('--connect-timeout') + 1], '3');
assert.ok(args.includes('http://127.0.0.1:18080'));
}
});