diff --git a/src/server/index.js b/src/server/index.js index cac443d..143fe67 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -135,7 +135,7 @@ const deviceInventory = settings.appMode === 'gateway' vendor: createVendorLookup(), }) : null; -const localConnectivityDiagnostics = settings.appMode === 'gateway' && !remoteDataplane +const localConnectivityDiagnostics = !remoteDataplane ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) : null; let subscriptionRefreshPromise = null; @@ -699,7 +699,6 @@ async function handleApi(req, res) { } 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 state = stateStore.read(); const appliedServerId = state.appliedServerId || state.selectedServerId; diff --git a/src/server/singbox.js b/src/server/singbox.js index 61252ba..8bb8b3b 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -25,12 +25,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { } = {}) { const clientMode = settings.appMode === 'client'; const directClient = clientMode && clientDirect; - const vpnOutbound = directClient - ? null - : structuredClone(findOutbound(subscriptionConfig, selectedTag)); - if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND'); - if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; - if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) { + const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag)); + if (!vpnOutbound) throw new HarborError('SERVER_NOT_FOUND'); + if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; + if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = 'xudp'; } const outboundTag = directClient ? 'direct' : vpnOutbound.tag; @@ -52,20 +50,24 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { sniff: true, set_system_proxy: false, }, - ...(!clientMode ? [{ + { type: 'mixed', tag: DIAGNOSTICS_INBOUND, listen: '127.0.0.1', listen_port: settings.diagnosticsProxyPort, sniff: true, set_system_proxy: false, - }] : []), + }, ]; const directRules = normalizeRouteRules(routeRules) .filter((rule) => rule.enabled) .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); 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 }, ...directRules, @@ -81,7 +83,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { dns: { independent_cache: true }, inbounds, outbounds: [ - ...(vpnOutbound ? [vpnOutbound] : []), + vpnOutbound, { type: 'direct', tag: 'direct' }, { type: 'block', tag: 'block' }, ], diff --git a/src/shared/versions.js b/src/shared/versions.js index 9b852fa..315bef8 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.16.2', - gatewayClient: '0.17.2', - gatewayBackend: '0.17.1', + macClient: '0.17.1', + gatewayClient: '0.18.1', + gatewayBackend: '0.18.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index f6f7211..16abec8 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1168,13 +1168,13 @@ export function ClientOverviewPage({ Устройства } - {isGateway && } +
- Gateway · Direct ↔ VPN + {isGateway ? 'Gateway' : 'Connect'} · Direct ↔ VPN

Маршруты

diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index a78882e..1424e24 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -589,7 +589,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { onClick={() => copyDeviceIp(device)} >{device.ip} : !hasName && Неизвестное устройство} - + {!hasName && Изменить название - + } )} diff --git a/src/web/styles.css b/src/web/styles.css index 5d47170..d908e73 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -946,7 +946,11 @@ p { display: flex; align-items: flex-end; 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 { @@ -1396,7 +1400,10 @@ p { .client-device-traffic-plot { grid-column: 1 / -1; grid-row: 1; + height: 100%; + min-height: 0; min-width: 0; + overflow: hidden; cursor: crosshair; } @@ -1408,7 +1415,7 @@ p { width: 100%; height: 100%; display: block; - overflow: visible; + overflow: hidden; } .client-device-traffic-grid line { diff --git a/test/server/connectivity-diagnostics.test.js b/test/server/connectivity-diagnostics.test.js index 48a8f83..c784135 100644 --- a/test/server/connectivity-diagnostics.test.js +++ b/test/server/connectivity-diagnostics.test.js @@ -1,10 +1,14 @@ 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 '../../src/server/services/connectivityDiagnosticsService.js'; +const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.js'), 'utf8'); + function response(body = '', overrides = {}) { return { 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'))); }); +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 () => { const attempts = new Map(); const execute = async (args) => { diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index d27e3ff..3b0bce0 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -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[1].listen_port, 18080); assert.deepEqual(config.route.rules, [ + { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { domain_suffix: ['ru'], outbound: 'direct' }, { domain: ['example.com'], outbound: 'direct' }, { 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, [ + { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { domain_suffix: ['ru'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: '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']); }); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 67cccb4..06be5be 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -433,9 +433,9 @@ setInterval(() => {}, 60_000); assert.equal(routed.state.route.localRulesPendingRestart, false); 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), [ + { inbound: ['diagnostics-vpn-in'], outbound: testServerId }, { domain: ['example.com'], outbound: 'direct' }, { domain_suffix: ['example.org'], outbound: 'direct' }, - { inbound: ['mixed-in'], outbound: testServerId }, ]); await mutation('/api/singbox/stop'); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 6fbf985..a16f19c 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -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, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/); assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/); - assert.match(panel, //); - assert.doesNotMatch(panel, /\{!hasName && /); assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/); assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/); 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-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-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-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\)/); @@ -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\.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, /\.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-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/); diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index 286bbfe..a8daeee 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -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-diagnostics-toggle/); assert.match(component, /