Add enabled local routing rules and gateway version reporting
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-11 22:14:43 +03:00
parent a0c66edb02
commit 1304a22f1f
22 changed files with 585 additions and 188 deletions

View File

@@ -16,6 +16,9 @@ test('gateway deploy updates control without recreating dataplane', () => {
assert.match(compose, /DATAPLANE_SOCKET: \/run\/vpn-proxy\/dataplane\.sock/);
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singboxRuntime\|version\)/);
assert.match(workflow, /src\/shared\/errors/);
assert.doesNotMatch(workflow, /dataplaneClient/);
});
test('runtime images include shared server modules', () => {

View File

@@ -24,8 +24,9 @@ const subscriptionConfig = {
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_keyword', value: 'cdn' },
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: false },
],
});
@@ -34,7 +35,6 @@ test('client exposes one local proxy and routes local exceptions before the sele
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ domain_keyword: ['cdn'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
@@ -42,7 +42,10 @@ test('client exposes one local proxy and routes local exceptions before the sele
});
test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true });
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
clientDirect: true,
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },

View File

@@ -22,7 +22,9 @@ const subscriptionConfig = {
};
test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [

View File

@@ -169,10 +169,9 @@ setInterval(() => {}, 60_000);
});
assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, 'test-vpn');
assert.deepEqual(initial.route.localRules, {
builtIn: [{ type: 'domain_suffix', value: 'ru' }],
custom: [],
});
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const stateKeys = Object.keys(initial).sort();
let revision = initial.revision;
@@ -242,18 +241,20 @@ setInterval(() => {}, 60_000);
const routed = await mutation('/api/route-rules', 'PUT', {
expectedRevision: rulesRevision,
rules: [
{ type: 'domain', value: 'https://Example.com/private?q=1' },
{ type: 'domain_suffix', value: '*.Example.org' },
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
],
});
assert.deepEqual(routed.state.route.localRules.custom, [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: 'example.org' },
assert.deepEqual(routed.state.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
]);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
@@ -270,7 +271,7 @@ setInterval(() => {}, 60_000);
});
assert.equal(staleRules.response.status, 409);
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
assert.deepEqual((await request(port, '/api/state')).route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
@@ -291,7 +292,7 @@ setInterval(() => {}, 60_000);
assert.equal(failedRules.response.status, 422);
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
const rolledBack = await request(port, '/api/state');
assert.deepEqual(rolledBack.route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual(rolledBack.route.localRules, routed.state.route.localRules);
assert.equal(rolledBack.connection.process, 'running');
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
revision = rolledBack.revision;
@@ -317,7 +318,7 @@ if (process.argv[2] === 'check') {
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');

View File

@@ -33,10 +33,10 @@ test('a failure before rename preserves the last successful file', (t) => {
);
});
test('schema v1 state migrates to the current schema and keeps a backup', (t) => {
test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 1,
schemaVersion: 2,
revision: 7,
selectedTag: 'nl',
servers: [{ tag: 'nl' }],
@@ -49,8 +49,11 @@ test('schema v1 state migrates to the current schema and keeps a backup', (t) =>
const migrated = store.read();
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(store.migration.fromVersion, 1);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVersionInfo } from '../../src/server/version.js';
import { buildGatewayVersionInfo, 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', () => {
@@ -22,6 +22,24 @@ test('component versions enforce one Harbor major and one Gateway major.minor',
}).compatible, false);
});
test('gateway reports control backend and deployed dataplane separately', () => {
const control = {
apiVersion: 1,
location: 'gateway',
components: { gatewayBackend: '0.4.0' },
runtime: { singBox: '1.12.13' },
};
assert.deepEqual(buildGatewayVersionInfo(control, {
gatewayBackendVersion: '0.3.0',
singBoxVersion: '1.12.13',
}), {
apiVersion: 1,
location: 'gateway',
components: { gatewayBackend: '0.4.0' },
runtime: { dataplaneVersion: '0.3.0', singBox: '1.12.13' },
});
});
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), {

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeRouteRules } from '../../src/shared/routingRules.js';
import { canAppendRouteRule, normalizeRouteRules } from '../../src/shared/routingRules.js';
test('local route rules normalize URLs, suffixes and duplicates', () => {
assert.deepEqual(normalizeRouteRules([
@@ -9,12 +9,15 @@ test('local route rules normalize URLs, suffixes and duplicates', () => {
{ type: 'domain_suffix', value: '*.Example.org' },
{ type: 'domain_keyword', value: ' CDN ' },
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: '.ru' },
{ type: 'domain_suffix', value: '.ru', enabled: false },
], { strict: true }), [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: 'example.org' },
{ type: 'domain_keyword', value: 'cdn' },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: true },
{ type: 'domain_suffix', value: 'ru', enabled: false },
]);
assert.equal(canAppendRouteRule([{ value: 'filled' }]), true);
assert.equal(canAppendRouteRule([{ value: '' }]), false);
});
test('invalid local route rules fail at the strict boundary', () => {