From 7a6f9a26acc1aa813f84f23b7e9d98c23f259bf2 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 21:24:18 +0300 Subject: [PATCH] Add runtime version reporting and display --- README.md | 6 ++ src/server/dataplane.js | 4 + src/server/index.js | 12 +++ src/server/version.js | 20 ++++ src/shared/versions.js | 27 +++++ src/web/App.jsx | 13 +++ src/web/api.js | 1 + src/web/components/ClientOverviewPage.jsx | 97 ++++++++++++++++++ src/web/styles.css | 117 ++++++++++++++++++++++ test/server/dataplane-client.test.js | 12 ++- test/server/state-contract.test.js | 11 ++ test/server/version.test.js | 33 ++++++ 12 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/server/version.js create mode 100644 src/shared/versions.js create mode 100644 test/server/version.test.js diff --git a/README.md b/README.md index eaca8e4..5890714 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ cd ~/.vpn-proxy-client Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`. +### Версии + +Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает одну строку `M`, Gateway — строки `C` (client) и `B` (backend). Наведите курсор или переведите клавиатурный фокус на любую цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у backend там же указана фактическая версия `sing-box` из dataplane. + +Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`. + ## Настройки `.env` Для большинства установок достаточно стандартных значений. diff --git a/src/server/dataplane.js b/src/server/dataplane.js index 9530bed..aa1bf5e 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -3,6 +3,7 @@ import http from 'node:http'; import path from 'node:path'; import { settings } from './config.js'; import { createSingboxRuntime } from './singboxRuntime.js'; +import { buildVersionInfo } from './version.js'; const socketPath = settings.dataplaneSocket; const runtime = createSingboxRuntime({ @@ -10,6 +11,7 @@ const runtime = createSingboxRuntime({ gateway: true, tproxyChain: settings.tproxyChain, }); +const versionInfo = buildVersionInfo('gateway'); let ready = false; function sendJson(res, statusCode, payload) { @@ -22,6 +24,8 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/status') { return sendJson(res, ready ? 200 : 503, { ...await runtime.refresh(), + gatewayBackendVersion: versionInfo.components.gatewayBackend, + singBoxVersion: versionInfo.runtime.singBox, ready, }); } diff --git a/src/server/index.js b/src/server/index.js index b63cc64..6b46860 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -31,6 +31,7 @@ import { } from '../shared/contracts/state.js'; import { HarborError, normalizeHarborError } from '../shared/errors.js'; import { createJsonStore, createStateStore } from './services/stateStore.js'; +import { buildVersionInfo } from './version.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -63,6 +64,7 @@ if (stateStore.recovery) { } const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET); +const versionInfo = buildVersionInfo(settings.appMode); const singboxRuntime = remoteDataplane ? createDataplaneClient(settings.dataplaneSocket) : createSingboxRuntime({ @@ -462,6 +464,16 @@ async function handleApi(req, res) { return sendJson(res, 200, await publicState()); } + if (req.method === 'GET' && req.url === '/api/version') { + if (!remoteDataplane) return sendJson(res, 200, versionInfo); + const runtime = await singboxRuntime.refresh(); + return sendJson(res, 200, { + ...versionInfo, + components: { gatewayBackend: runtime.gatewayBackendVersion || null }, + runtime: { singBox: runtime.singBoxVersion || null }, + }); + } + if (req.method === 'GET' && req.url === '/api/shared-proxy') { return sendJson(res, 200, buildSharedProxyInfo({ appMode: settings.appMode, diff --git a/src/server/version.js b/src/server/version.js new file mode 100644 index 0000000..1a1c51d --- /dev/null +++ b/src/server/version.js @@ -0,0 +1,20 @@ +import { spawnSync } from 'node:child_process'; +import { HARBOR_VERSIONS } from '../shared/versions.js'; + +export function detectSingBoxVersion(run = spawnSync) { + const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 }); + const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`); + return match?.[1] || null; +} + +export function buildVersionInfo(appMode, run = spawnSync) { + const client = appMode === 'client'; + return { + apiVersion: 1, + location: client ? 'mac' : 'gateway', + components: client + ? { macClient: HARBOR_VERSIONS.macClient } + : { gatewayBackend: HARBOR_VERSIONS.gatewayBackend }, + runtime: { singBox: detectSingBoxVersion(run) }, + }; +} diff --git a/src/shared/versions.js b/src/shared/versions.js new file mode 100644 index 0000000..e2870df --- /dev/null +++ b/src/shared/versions.js @@ -0,0 +1,27 @@ +export const HARBOR_VERSIONS = Object.freeze({ + macClient: '0.1.0', + gatewayClient: '0.1.0', + gatewayBackend: '0.1.0', +}); + +export function parseVersion(value) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || '')); + return match ? { + major: Number(match[1]), + minor: Number(match[2]), + hotfix: Number(match[3]), + } : null; +} + +export function versionCompatibility(versions) { + const mac = parseVersion(versions?.macClient); + const client = parseVersion(versions?.gatewayClient); + const backend = parseVersion(versions?.gatewayBackend); + const major = Boolean(mac && client && backend + && mac.major === client.major + && client.major === backend.major); + const gateway = Boolean(client && backend + && client.major === backend.major + && client.minor === backend.minor); + return { compatible: major && gateway, major, gateway }; +} diff --git a/src/web/App.jsx b/src/web/App.jsx index c4f0b5d..625edfc 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -20,6 +20,7 @@ function App() { const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [operations, setOperations] = useState({}); const [error, setError] = useState(null); + const [versionInfo, setVersionInfo] = useState(null); const pollGeneration = useRef(0); const operationRegistry = useRef(null); if (!operationRegistry.current) { @@ -56,6 +57,17 @@ function App() { return () => clearInterval(timer); }, []); + useEffect(() => { + let cancelled = false; + api.version().then((info) => { + if (!cancelled) setVersionInfo(info); + }).catch((requestError) => { + console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`); + if (!cancelled) setVersionInfo(null); + }); + return () => { cancelled = true; }; + }, []); + useEffect(() => { if (!state?.mode) return; const isGateway = state.mode === 'gateway'; @@ -129,6 +141,7 @@ function App() {
request('/api/state'), + version: () => request('/api/version'), subscription: { validate: (url, signal) => request('/api/subscription/validate', { method: 'POST', diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index af3890b..486458e 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -13,6 +13,11 @@ import { import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; import { createLatestRequest, operationBlocked } from '../state/operations.js'; +import { + HARBOR_VERSIONS, + parseVersion, + versionCompatibility, +} from '../../shared/versions.js'; const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode'; @@ -21,6 +26,96 @@ function CloudTooltip({ children }) { return {children}; } +const VERSION_PARTS = [ + ['major', 'Major'], + ['minor', 'Minor'], + ['hotfix', 'Hotfix'], +]; + +function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) { + const parsed = parseVersion(version); + const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–']; + + function description(key) { + if (key === 'major') { + return 'Общий уровень совместимости Harbor. При его изменении обновляются Mac и вся Gateway-инфраструктура.'; + } + if (key === 'minor' && componentKey === 'macClient') { + return 'Линия Mac-клиента. Gateway может менять minor без обязательного обновления Mac.'; + } + if (key === 'minor') { + return 'Линия Gateway. Gateway client и backend должны совпадать по major.minor.'; + } + return `Совместимое исправление только компонента ${component}; hotfix может обновляться независимо.`; + } + + return ( +
+ + + {VERSION_PARTS.map(([key, label], index) => { + const tooltipId = `harbor-version-${componentKey}-${key}`; + return + {index > 0 && } + + {values[index]} + + {component} · {label} {values[index]} + {description(key)} + {singBox && Runtime: sing-box {singBox}} + {incompatible && Версии Gateway несовместимы.} + + + ; + })} + +
+ ); +} + +function VersionDisplay({ isGateway, versionInfo }) { + const runtimeSingBox = versionInfo?.runtime?.singBox; + if (!isGateway) { + return ; + } + + const backendVersion = versionInfo?.components?.gatewayBackend; + const compatibility = backendVersion && versionCompatibility({ + ...HARBOR_VERSIONS, + gatewayBackend: backendVersion, + }); + const incompatible = compatibility && !compatibility.compatible; + return ; +} + function InlineError({ error, context }) { if (!error || error.context !== context) return null; return ( @@ -198,6 +293,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe export function ClientOverviewPage({ state, + versionInfo, operations = {}, error, subscriptionUrl, @@ -553,6 +649,7 @@ export function ClientOverviewPage({ return (
+ span, .client-state-detail > * { diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js index 0acaa37..d892a39 100644 --- a/test/server/dataplane-client.test.js +++ b/test/server/dataplane-client.test.js @@ -6,11 +6,19 @@ test('control uses the dataplane socket protocol', async () => { const requests = []; const send = async (socketPath, pathname, method) => { requests.push(`${method} ${pathname} ${socketPath}`); - return { running: pathname !== '/stop', startedAt: 'now' }; + return { + running: pathname !== '/stop', + startedAt: 'now', + gatewayBackendVersion: '0.1.0', + singBoxVersion: '1.12.13', + }; }; const client = createDataplaneClient('/run/dataplane.sock', send); - assert.equal((await client.refresh()).running, true); + const status = await client.refresh(); + assert.equal(status.running, true); + assert.equal(status.gatewayBackendVersion, '0.1.0'); + assert.equal(status.singBoxVersion, '1.12.13'); await client.apply(); await client.restart(); assert.equal((await client.stop()).running, false); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 0233379..7c46930 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -103,6 +103,10 @@ test('GET and domain mutations return one state shape with monotonic revisions', const singboxPath = path.join(binDir, 'sing-box'); const workingSingbox = `#!/usr/bin/env node if (process.argv[2] === 'check') process.exit(0); +if (process.argv[2] === 'version') { + console.log('sing-box version 1.12.13'); + process.exit(0); +} process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 60_000); `; @@ -155,6 +159,13 @@ setInterval(() => {}, 60_000); }); const initial = await waitForState(port, child, () => stderr); + const version = await request(port, '/api/version'); + assert.deepEqual(version, { + apiVersion: 1, + location: 'mac', + components: { macClient: '0.1.0' }, + runtime: { singBox: '1.12.13' }, + }); assertStateSnapshot(initial); assert.equal(initial.selection.appliedServerId, 'test-vpn'); assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false); diff --git a/test/server/version.test.js b/test/server/version.test.js new file mode 100644 index 0000000..0fb75f2 --- /dev/null +++ b/test/server/version.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildVersionInfo } from '../../src/server/version.js'; +import { HARBOR_VERSIONS, versionCompatibility } from '../../src/shared/versions.js'; + +test('component versions enforce one Harbor major and one Gateway major.minor', () => { + assert.deepEqual(versionCompatibility(HARBOR_VERSIONS), { + compatible: true, + major: true, + gateway: true, + }); + assert.equal(versionCompatibility({ + macClient: '1.9.4', + gatewayClient: '1.3.1', + gatewayBackend: '1.3.8', + }).compatible, true); + assert.equal(versionCompatibility({ + macClient: '1.0.0', + gatewayClient: '1.3.0', + gatewayBackend: '1.4.0', + }).compatible, false); +}); + +test('runtime version info reports the installed sing-box binary', () => { + const run = () => ({ stdout: 'sing-box version 1.12.13\n', stderr: '' }); + assert.deepEqual(buildVersionInfo('gateway', run), { + apiVersion: 1, + location: 'gateway', + components: { gatewayBackend: HARBOR_VERSIONS.gatewayBackend }, + runtime: { singBox: '1.12.13' }, + }); +});