From 306a9b8ceda10c28f0110a9e8356c9894bc034d9 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 21:36:04 +0300 Subject: [PATCH] Bump Harbor versions and add direct .ru routing --- .codex/skills/manage-harbor-versions/SKILL.md | 26 +++ .../manage-harbor-versions/agents/openai.yaml | 4 + AGENTS.md | 2 + README.md | 2 + package.json | 1 + scripts/harbor-version.mjs | 177 ++++++++++++++++++ src/server/singbox.js | 4 +- src/shared/versions.js | 6 +- src/web/components/ClientOverviewPage.jsx | 9 +- src/web/styles.css | 26 +-- test/server/singbox-client-mode.test.js | 2 + test/server/singbox-gateway-mode.test.js | 3 +- test/server/state-contract.test.js | 3 +- test/version-script.test.js | 39 ++++ 14 files changed, 279 insertions(+), 25 deletions(-) create mode 100644 .codex/skills/manage-harbor-versions/SKILL.md create mode 100644 .codex/skills/manage-harbor-versions/agents/openai.yaml create mode 100644 scripts/harbor-version.mjs create mode 100644 test/version-script.test.js diff --git a/.codex/skills/manage-harbor-versions/SKILL.md b/.codex/skills/manage-harbor-versions/SKILL.md new file mode 100644 index 0000000..0aa4ab0 --- /dev/null +++ b/.codex/skills/manage-harbor-versions/SKILL.md @@ -0,0 +1,26 @@ +--- +name: manage-harbor-versions +description: Check and bump Harbor component versions for every runtime, UI, API, dependency, packaging, or deployment-config change in this repository. Use before completing implementation work, release preparation, or any change that can alter the shipped Mac client, Gateway client, or Gateway backend. +--- + +# Manage Harbor Versions + +Treat `src/shared/versions.js` as the only component-version source. Do not use the root package version as a release version. + +## Required workflow + +1. Inspect the complete diff and choose the comparison base, normally `HEAD` for working-tree changes or the target branch for a review. +2. Run `npm run version:harbor -- affected `. +3. Classify the highest compatibility impact: + - `major`: changes an ecosystem contract or requires all cooperating components and clients to update; + - `minor`: changes one component and its tightly linked components while remaining compatible with other clients on the same major; + - `hotfix`: changes only the affected component without requiring linked components or other clients to update. +4. Run one explicit bump command: + - `npm run version:harbor -- bump major` + - `npm run version:harbor -- bump minor ` + - `npm run version:harbor -- bump hotfix ` +5. Run `npm run version:harbor -- check ` and the repository tests before completion. + +Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component. + +Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.js`, keep the initial versions and let the checker report that no baseline exists. diff --git a/.codex/skills/manage-harbor-versions/agents/openai.yaml b/.codex/skills/manage-harbor-versions/agents/openai.yaml new file mode 100644 index 0000000..56401a8 --- /dev/null +++ b/.codex/skills/manage-harbor-versions/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Manage Harbor Versions" + short_description: "Check and bump Harbor component versions." + default_prompt: "Use $manage-harbor-versions to classify changes and update the required Harbor component versions." diff --git a/AGENTS.md b/AGENTS.md index 3e67d9a..866bb7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,5 @@ Use the checked-in `workpack/` directory as the only roadmap source. Do not require or read the original archive. Follow `workpack/AGENTS.md` for every roadmap task, including status updates. Completed tasks must not be selected or implemented again unless the user explicitly asks to reopen one. + +For every runtime, UI, API, dependency or deployment-config change, use `.codex/skills/manage-harbor-versions/SKILL.md`. Before completion, classify the affected components, bump the required version level and run `npm run version:harbor -- check `. Documentation- and test-only changes do not require a bump. diff --git a/README.md b/README.md index 5890714..7e02c0b 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ cd ~/.vpn-proxy-client Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`. +Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`. + ## Настройки `.env` Для большинства установок достаточно стандартных значений. diff --git a/package.json b/package.json index a2d1008..aed71f5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite --host 0.0.0.0", "build": "vite build", "test": "node --test", + "version:harbor": "node scripts/harbor-version.mjs", "start": "node src/server/index.js" }, "dependencies": { diff --git a/scripts/harbor-version.mjs b/scripts/harbor-version.mjs new file mode 100644 index 0000000..6403636 --- /dev/null +++ b/scripts/harbor-version.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseVersion, versionCompatibility } from '../src/shared/versions.js'; + +const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend']; +const VERSION_FILE = 'src/shared/versions.js'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const aliases = { + mac: 'macClient', + 'mac-client': 'macClient', + client: 'gatewayClient', + 'gateway-client': 'gatewayClient', + backend: 'gatewayBackend', + 'gateway-backend': 'gatewayBackend', +}; + +export function versionsFromSource(source) { + return Object.fromEntries(COMPONENTS.map((component) => { + const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source); + if (!match) throw new Error(`Не найдена версия ${component}`); + return [component, match[1]]; + })); +} + +export function affectedComponents(files) { + const affected = new Set(); + const add = (...components) => components.forEach((component) => affected.add(component)); + for (const file of files) { + if (file === VERSION_FILE) continue; + if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS); + else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) { + add('macClient', 'gatewayClient'); + } else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend'); + else if (/^(Dockerfile\.client|docker-compose\.client\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) { + add('macClient'); + } else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) { + add('gatewayBackend'); + } + } + return COMPONENTS.filter((component) => affected.has(component)); +} + +function formatVersion({ major, minor, hotfix }) { + return `${major}.${minor}.${hotfix}`; +} + +export function bumpVersions(versions, level, requested = []) { + const current = Object.fromEntries(COMPONENTS.map((component) => { + const parsed = parseVersion(versions[component]); + if (!parsed) throw new Error(`Некорректная версия ${component}: ${versions[component]}`); + return [component, parsed]; + })); + if (level === 'major') { + const major = Math.max(...COMPONENTS.map((component) => current[component].major)) + 1; + return Object.fromEntries(COMPONENTS.map((component) => [component, `${major}.0.0`])); + } + + const targets = new Set(requested.map((target) => aliases[target] || target)); + if (!targets.size) throw new Error(`${level} требует хотя бы один компонент`); + for (const target of targets) { + if (!COMPONENTS.includes(target)) throw new Error(`Неизвестный компонент: ${target}`); + } + if (level === 'minor' && (targets.has('gatewayClient') || targets.has('gatewayBackend'))) { + targets.add('gatewayClient'); + targets.add('gatewayBackend'); + } + if (!['minor', 'hotfix'].includes(level)) throw new Error(`Неизвестный уровень: ${level}`); + + const next = { ...versions }; + if (level === 'minor' && targets.has('gatewayClient')) { + const minor = Math.max(current.gatewayClient.minor, current.gatewayBackend.minor) + 1; + next.gatewayClient = `${current.gatewayClient.major}.${minor}.0`; + next.gatewayBackend = `${current.gatewayBackend.major}.${minor}.0`; + targets.delete('gatewayClient'); + targets.delete('gatewayBackend'); + } + for (const target of targets) { + const value = current[target]; + next[target] = level === 'minor' + ? `${value.major}.${value.minor + 1}.0` + : formatVersion({ ...value, hotfix: value.hotfix + 1 }); + } + return next; +} + +function git(args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function changedFiles(base) { + const tracked = git(['diff', '--name-only', base, '--']).split('\n'); + const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n'); + return [...new Set([...tracked, ...untracked].filter(Boolean))]; +} + +function baselineVersions(base) { + try { + return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`])); + } catch { + return null; + } +} + +function compareVersions(before, after) { + const left = parseVersion(before); + const right = parseVersion(after); + if (!left || !right) return -1; + for (const key of ['major', 'minor', 'hotfix']) { + if (right[key] !== left[key]) return right[key] > left[key] ? 1 : -1; + } + return 0; +} + +function validateCompatibility(versions) { + const compatibility = versionCompatibility(versions); + if (!compatibility.major) throw new Error('У всех компонентов должен совпадать major'); + if (!compatibility.gateway) throw new Error('Gateway client и backend должны совпадать по major.minor'); +} + +function writeVersions(versions) { + const file = path.join(root, VERSION_FILE); + let source = fs.readFileSync(file, 'utf8'); + for (const component of COMPONENTS) { + source = source.replace( + new RegExp(`(${component}:\\s*')\\d+\\.\\d+\\.\\d+(')`), + `$1${versions[component]}$2`, + ); + } + fs.writeFileSync(file, source); +} + +function printVersions(versions) { + for (const component of COMPONENTS) console.log(`${component}: ${versions[component]}`); +} + +function main([command = 'check', ...args]) { + const source = fs.readFileSync(path.join(root, VERSION_FILE), 'utf8'); + const current = versionsFromSource(source); + validateCompatibility(current); + + if (command === 'bump') { + const next = bumpVersions(current, args[0], args.slice(1)); + validateCompatibility(next); + writeVersions(next); + printVersions(next); + return; + } + + const base = args[0] || 'HEAD'; + const affected = affectedComponents(changedFiles(base)); + if (command === 'affected') { + console.log(affected.length ? affected.join('\n') : 'Нет изменений, требующих bump.'); + return; + } + if (command !== 'check') throw new Error(`Неизвестная команда: ${command}`); + + const baseline = baselineVersions(base); + if (!baseline) { + console.log('Version contract создаётся впервые; baseline для bump отсутствует.'); + return; + } + const missing = affected.filter((component) => compareVersions(baseline[component], current[component]) <= 0); + if (missing.length) throw new Error(`Не повышена версия: ${missing.join(', ')}`); + console.log(affected.length ? `Version check: ${affected.join(', ')}` : 'Version check: bump не требуется.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`[harbor-version] ${error.message}`); + process.exitCode = 1; + } +} diff --git a/src/server/singbox.js b/src/server/singbox.js index 9ef1360..c3eb8ae 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -48,9 +48,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire set_system_proxy: false, }, ]; + const directRules = [{ domain_suffix: ['ru'], outbound: 'direct' }]; const rules = clientMode - ? [{ inbound: [MIXED_INBOUND], outbound: outboundTag }] + ? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }] : [ + ...directRules, { inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ]; diff --git a/src/shared/versions.js b/src/shared/versions.js index e2870df..195baa5 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.1.0', - gatewayClient: '0.1.0', - gatewayBackend: '0.1.0', + macClient: '0.2.0', + gatewayClient: '0.2.0', + gatewayBackend: '0.2.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 486458e..94b28c7 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -38,15 +38,12 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa function description(key) { if (key === 'major') { - return 'Общий уровень совместимости Harbor. При его изменении обновляются Mac и вся Gateway-инфраструктура.'; - } - if (key === 'minor' && componentKey === 'macClient') { - return 'Линия Mac-клиента. Gateway может менять minor без обязательного обновления Mac.'; + return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.'; } if (key === 'minor') { - return 'Линия Gateway. Gateway client и backend должны совпадать по major.minor.'; + return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.'; } - return `Совместимое исправление только компонента ${component}; hotfix может обновляться независимо.`; + return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.'; } return ( diff --git a/src/web/styles.css b/src/web/styles.css index 37bfb89..8cb3a19 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -199,16 +199,16 @@ p { z-index: 40; display: grid; justify-items: end; - gap: 2px; + gap: 4px; color: var(--client-muted); - font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + font: 650 12px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; } .harbor-version { display: flex; align-items: center; - gap: 4px; + gap: 6px; opacity: 0.56; transition: color 240ms ease, opacity 240ms ease; } @@ -227,7 +227,7 @@ p { .harbor-version-code { width: 1.3ch; color: var(--client-accent); - font-size: 8px; + font-size: 10px; text-align: center; } @@ -239,8 +239,8 @@ p { .harbor-version-part { position: relative; min-width: 1ch; - padding: 3px 1px; - border-radius: 4px; + padding: 5px 2px; + border-radius: 5px; cursor: help; text-align: center; } @@ -260,16 +260,16 @@ p { position: absolute; right: 0; bottom: calc(100% + 7px); - width: min(250px, calc(100vw - 28px)); + width: min(300px, calc(100vw - 28px)); display: grid; - gap: 5px; - padding: 9px 10px; - border-radius: 10px; + gap: 7px; + padding: 12px 13px; + border-radius: 12px; background: color-mix(in oklch, var(--client-panel) 84%, transparent); box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16); backdrop-filter: blur(12px) saturate(0.9); color: var(--client-muted); - font-size: 9px; + font-size: 11px; font-weight: 500; line-height: 1.5; text-align: left; @@ -283,12 +283,12 @@ p { .harbor-version-tooltip strong { color: var(--client-text); - font-size: 9px; + font-size: 11px; } .harbor-version-tooltip small { color: var(--client-accent); - font-size: 8px; + font-size: 10px; } .harbor-version-tooltip .is-warning { diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 141f62b..6b8fa14 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -27,6 +27,7 @@ test('client exposes one local proxy and routes it through the selected VPN', () assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']); assert.equal(config.inbounds[0].listen_port, 8082); assert.deepEqual(config.route.rules, [ + { domain_suffix: ['ru'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); assert.equal(config.route.final, 'test-vpn'); @@ -37,6 +38,7 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true }); assert.deepEqual(config.route.rules, [ + { domain_suffix: ['ru'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'direct' }, ]); assert.equal(config.route.final, 'direct'); diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js index ca6d409..0d7fa78 100644 --- a/test/server/singbox-gateway-mode.test.js +++ b/test/server/singbox-gateway-mode.test.js @@ -21,11 +21,12 @@ const subscriptionConfig = { }], }; -test('gateway routes transparent and proxy traffic only through the selected VPN', () => { +test('gateway routes .ru domains directly and other traffic through the selected VPN', () => { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn'); assert.deepEqual(config.route.rule_set, []); assert.deepEqual(config.route.rules, [ + { domain_suffix: ['ru'], outbound: 'direct' }, { inbound: ['tproxy-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 7c46930..4a9eb66 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -11,6 +11,7 @@ import { createStateSnapshot, normalizeStoredState, } from '../../src/shared/contracts/state.js'; +import { HARBOR_VERSIONS } from '../../src/shared/versions.js'; const root = path.resolve(import.meta.dirname, '../..'); @@ -163,7 +164,7 @@ setInterval(() => {}, 60_000); assert.deepEqual(version, { apiVersion: 1, location: 'mac', - components: { macClient: '0.1.0' }, + components: { macClient: HARBOR_VERSIONS.macClient }, runtime: { singBox: '1.12.13' }, }); assertStateSnapshot(initial); diff --git a/test/version-script.test.js b/test/version-script.test.js new file mode 100644 index 0000000..1cd09b0 --- /dev/null +++ b/test/version-script.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { affectedComponents, bumpVersions } from '../scripts/harbor-version.mjs'; + +const versions = { + macClient: '2.4.3', + gatewayClient: '2.7.1', + gatewayBackend: '2.7.8', +}; + +test('version paths map to the components actually shipped by this repository', () => { + assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']); + assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']); + assert.deepEqual(affectedComponents(['package-lock.json']), [ + 'macClient', + 'gatewayClient', + 'gatewayBackend', + ]); + assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []); +}); + +test('version bumps follow ecosystem, linked-component and local scopes', () => { + assert.deepEqual(bumpVersions(versions, 'major'), { + macClient: '3.0.0', + gatewayClient: '3.0.0', + gatewayBackend: '3.0.0', + }); + assert.deepEqual(bumpVersions(versions, 'minor', ['gateway-backend']), { + macClient: '2.4.3', + gatewayClient: '2.8.0', + gatewayBackend: '2.8.0', + }); + assert.deepEqual(bumpVersions(versions, 'hotfix', ['mac']), { + macClient: '2.4.4', + gatewayClient: '2.7.1', + gatewayBackend: '2.7.8', + }); +});