Bump Harbor versions and add direct .ru routing
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-11 21:36:04 +03:00
parent 7a6f9a26ac
commit 306a9b8ced
14 changed files with 279 additions and 25 deletions

View File

@@ -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 <base>`.
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 <components...>`
- `npm run version:harbor -- bump hotfix <components...>`
5. Run `npm run version:harbor -- check <base>` 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.

View File

@@ -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."

View File

@@ -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 <base>`. Documentation- and test-only changes do not require a bump.

View File

@@ -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 <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
## Настройки `.env`
Для большинства установок достаточно стандартных значений.

View File

@@ -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": {

177
scripts/harbor-version.mjs Normal file
View File

@@ -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;
}
}

View File

@@ -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 },
];

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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' },
]);

View File

@@ -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);

View File

@@ -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',
});
});