Enable connectivity diagnostics in Connect mode
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 21:43:09 +03:00
parent 70cc221f34
commit d32bc14108
12 changed files with 60 additions and 31 deletions
+1 -2
View File
@@ -135,7 +135,7 @@ const deviceInventory = settings.appMode === 'gateway'
vendor: createVendorLookup(), vendor: createVendorLookup(),
}) })
: null; : null;
const localConnectivityDiagnostics = settings.appMode === 'gateway' && !remoteDataplane const localConnectivityDiagnostics = !remoteDataplane
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
: null; : null;
let subscriptionRefreshPromise = null; let subscriptionRefreshPromise = null;
@@ -699,7 +699,6 @@ async function handleApi(req, res) {
} }
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') { if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
const { services = [] } = await readBody(req); const { services = [] } = await readBody(req);
const state = stateStore.read(); const state = stateStore.read();
const appliedServerId = state.appliedServerId || state.selectedServerId; const appliedServerId = state.appliedServerId || state.selectedServerId;
+12 -10
View File
@@ -25,12 +25,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
} = {}) { } = {}) {
const clientMode = settings.appMode === 'client'; const clientMode = settings.appMode === 'client';
const directClient = clientMode && clientDirect; const directClient = clientMode && clientDirect;
const vpnOutbound = directClient const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
? null if (!vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
: structuredClone(findOutbound(subscriptionConfig, selectedTag)); if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND'); if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp'; vpnOutbound.packet_encoding = 'xudp';
} }
const outboundTag = directClient ? 'direct' : vpnOutbound.tag; const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
@@ -52,20 +50,24 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
sniff: true, sniff: true,
set_system_proxy: false, set_system_proxy: false,
}, },
...(!clientMode ? [{ {
type: 'mixed', type: 'mixed',
tag: DIAGNOSTICS_INBOUND, tag: DIAGNOSTICS_INBOUND,
listen: '127.0.0.1', listen: '127.0.0.1',
listen_port: settings.diagnosticsProxyPort, listen_port: settings.diagnosticsProxyPort,
sniff: true, sniff: true,
set_system_proxy: false, set_system_proxy: false,
}] : []), },
]; ];
const directRules = normalizeRouteRules(routeRules) const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled) .filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }] ? [
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
...directRules,
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
]
: [ : [
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules, ...directRules,
@@ -81,7 +83,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
dns: { independent_cache: true }, dns: { independent_cache: true },
inbounds, inbounds,
outbounds: [ outbounds: [
...(vpnOutbound ? [vpnOutbound] : []), vpnOutbound,
{ type: 'direct', tag: 'direct' }, { type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' }, { type: 'block', tag: 'block' },
], ],
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.16.2', macClient: '0.17.1',
gatewayClient: '0.17.2', gatewayClient: '0.18.1',
gatewayBackend: '0.17.1', gatewayBackend: '0.18.0',
}); });
export function parseVersion(value) { export function parseVersion(value) {
+5 -4
View File
@@ -1168,13 +1168,13 @@ export function ClientOverviewPage({
</svg> </svg>
<span>Устройства</span> <span>Устройства</span>
</button>} </button>}
{isGateway && <button <button
ref={diagnosticsToggleRef} ref={diagnosticsToggleRef}
className={`client-instructions-toggle client-diagnostics-toggle${diagnosticsOpen ? ' is-open' : ''}`} className={`client-instructions-toggle client-diagnostics-toggle${diagnosticsOpen ? ' is-open' : ''}`}
type="button" type="button"
aria-expanded={diagnosticsOpen} aria-expanded={diagnosticsOpen}
aria-controls="client-diagnostics" aria-controls="client-diagnostics"
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты Gateway'} aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
onClick={() => { onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return; if (localRulesOpen && !requestCloseLocalRules()) return;
setInstructionsOpen(false); setInstructionsOpen(false);
@@ -1186,7 +1186,7 @@ export function ClientOverviewPage({
<path d="M3 12h4l2.2-5 4.2 10 2.1-5H21" /> <path d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
</svg> </svg>
<span>Диагностика</span> <span>Диагностика</span>
</button>} </button>
<button <button
ref={localRulesToggleRef} ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`} className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
@@ -1536,7 +1536,8 @@ export function ClientOverviewPage({
onClose={() => setDevicesOpen(false)} onClose={() => setDevicesOpen(false)}
/>} />}
{hasSubscription && subscriptionContentReady && isGateway && <ConnectivityDiagnosticsPanel {hasSubscription && subscriptionContentReady && <ConnectivityDiagnosticsPanel
isGateway={isGateway}
open={diagnosticsOpen} open={diagnosticsOpen}
panelRef={diagnosticsPanelRef} panelRef={diagnosticsPanelRef}
closeRef={diagnosticsCloseRef} closeRef={diagnosticsCloseRef}
@@ -63,7 +63,7 @@ function IpCell({ path, source, pending, route }) {
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>; return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
} }
export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose }) { export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -138,7 +138,7 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
onClick={onClose} onClick={onClose}
>×</button> >×</button>
<header className="client-instructions-header client-diagnostics-header"> <header className="client-instructions-header client-diagnostics-header">
<span>Gateway · Direct VPN</span> <span>{isGateway ? 'Gateway' : 'Connect'} · Direct VPN</span>
<div className="client-diagnostics-title-row"> <div className="client-diagnostics-title-row">
<h2 id="client-diagnostics-title">Маршруты</h2> <h2 id="client-diagnostics-title">Маршруты</h2>
<span className="client-diagnostics-refresh-wrap client-tooltip-anchor"> <span className="client-diagnostics-refresh-wrap client-tooltip-anchor">
+2 -2
View File
@@ -589,7 +589,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={() => copyDeviceIp(device)} onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>} >{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
</h3> </h3>
<span className="client-device-edit-wrap client-tooltip-anchor"> {!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
<button <button
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`} className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
type="button" type="button"
@@ -607,7 +607,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</svg> </svg>
</button> </button>
<Tooltip>Изменить название</Tooltip> <Tooltip>Изменить название</Tooltip>
</span> </span>}
</> </>
)} )}
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0"> <span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
+9 -2
View File
@@ -946,7 +946,11 @@ p {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 2px; gap: 2px;
padding: 11px 27px 0 0; padding: 11px 0 0;
}
.client-device-main:has(.client-device-edit-wrap) {
padding-right: 27px;
} }
.client-device-main > h3 { .client-device-main > h3 {
@@ -1396,7 +1400,10 @@ p {
.client-device-traffic-plot { .client-device-traffic-plot {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 1; grid-row: 1;
height: 100%;
min-height: 0;
min-width: 0; min-width: 0;
overflow: hidden;
cursor: crosshair; cursor: crosshair;
} }
@@ -1408,7 +1415,7 @@ p {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
overflow: visible; overflow: hidden;
} }
.client-device-traffic-grid line { .client-device-traffic-grid line {
@@ -1,10 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { import {
createConnectivityDiagnosticsService, createConnectivityDiagnosticsService,
CURL_META_MARKER, CURL_META_MARKER,
} from '../../src/server/services/connectivityDiagnosticsService.js'; } from '../../src/server/services/connectivityDiagnosticsService.js';
const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.js'), 'utf8');
function response(body = '', overrides = {}) { function response(body = '', overrides = {}) {
return { return {
exitCode: 0, exitCode: 0,
@@ -48,6 +52,11 @@ test('connectivity diagnostics force separate direct and VPN paths', async () =>
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080'))); 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', () => {
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
});
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => { test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
const attempts = new Map(); const attempts = new Map();
const execute = async (args) => { const execute = async (args) => {
+8 -2
View File
@@ -30,9 +30,14 @@ test('client exposes one local proxy and routes local exceptions before the sele
], ],
}); });
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']); assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), [
'mixed-in',
'diagnostics-vpn-in',
]);
assert.equal(config.inbounds[0].listen_port, 8082); assert.equal(config.inbounds[0].listen_port, 8082);
assert.equal(config.inbounds[1].listen_port, 18080);
assert.deepEqual(config.route.rules, [ assert.deepEqual(config.route.rules, [
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' }, { domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' }, { domain: ['example.com'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' },
@@ -48,9 +53,10 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
}); });
assert.deepEqual(config.route.rules, [ assert.deepEqual(config.route.rules, [
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' }, { domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'direct' },
]); ]);
assert.equal(config.route.final, 'direct'); assert.equal(config.route.final, 'direct');
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['direct', 'block']); assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['test-vpn', 'direct', 'block']);
}); });
+1 -1
View File
@@ -433,9 +433,9 @@ setInterval(() => {}, 60_000);
assert.equal(routed.state.route.localRulesPendingRestart, false); assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules); assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
{ domain: ['example.com'], outbound: 'direct' }, { domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' }, { domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: testServerId },
]); ]);
await mutation('/api/singbox/stop'); await mutation('/api/singbox/stop');
+5 -3
View File
@@ -42,8 +42,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/); assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/); assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/); assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
assert.match(panel, /<span className="client-device-edit-wrap client-tooltip-anchor">/); assert.match(panel, /\{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">/);
assert.doesNotMatch(panel, /\{!hasName && <span className="client-device-edit-wrap/);
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/); assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/); assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/); assert.match(panel, /COPY_FEEDBACK_MS = 800/);
@@ -117,7 +116,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/); assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/); assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/); assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 27px 0 0/); assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
assert.match(styles, /\.client-device-main:has\(\.client-device-edit-wrap\) \{[\s\S]*padding-right: 27px/);
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/); assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/); assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/); assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
@@ -129,6 +129,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-edit-wrap \{[\s\S]*position: absolute;[\s\S]*right: 0;[\s\S]*bottom: 0/); assert.match(styles, /\.client-device-edit-wrap \{[\s\S]*position: absolute;[\s\S]*right: 0;[\s\S]*bottom: 0/);
assert.match(styles, /\.client-device-edit\.is-writing svg \{[\s\S]*client-device-pencil-write 620ms/); assert.match(styles, /\.client-device-edit\.is-writing svg \{[\s\S]*client-device-pencil-write 620ms/);
assert.match(styles, /@keyframes client-device-pencil-write[\s\S]*0%, 100%[\s\S]*translate\(1px, -1px\) rotate\(-5deg\)/); assert.match(styles, /@keyframes client-device-pencil-write[\s\S]*0%, 100%[\s\S]*translate\(1px, -1px\) rotate\(-5deg\)/);
assert.match(styles, /\.client-device-traffic-plot \{[\s\S]*height: 100%;[\s\S]*min-height: 0;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/); assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/); assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
@@ -91,6 +91,8 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/); assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
assert.match(component, /client-diagnostics-toggle/); assert.match(component, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/); assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /\{isGateway && <button[\s\S]{0,120}diagnosticsToggleRef/);
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
assert.match(component, /Локальные правила недоступны: сейчас работают правила Gateway/); assert.match(component, /Локальные правила недоступны: сейчас работают правила Gateway/);
assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/); assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/);
assert.match(rule('.client-secondary-menu'), /display:\s*grid/); assert.match(rule('.client-secondary-menu'), /display:\s*grid/);
@@ -117,6 +119,7 @@ test('connectivity diagnostics render stable compact tables before the first run
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 2); assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 2);
assert.match(diagnostics, /Добавить свой сервис/); assert.match(diagnostics, /Добавить свой сервис/);
assert.match(diagnostics, /client-diagnostics-refresh/); assert.match(diagnostics, /client-diagnostics-refresh/);
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/); assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
assert.match(diagnostics, /\{\(error \|\| result\) && <div className="client-diagnostics-feedback"/); assert.match(diagnostics, /\{\(error \|\| result\) && <div className="client-diagnostics-feedback"/);
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/); assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);