Update Harbor client and gateway functionality
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-17 15:23:16 +03:00
parent 0b39211fbd
commit 7c255192b0
41 changed files with 1443 additions and 218 deletions
+15 -2
View File
@@ -12,7 +12,7 @@ import { HarborError } from '../../dist/shared/errors.js';
const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' };
const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' };
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }];
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
id,
@@ -148,6 +148,7 @@ test('apply validates the profile pair and publishes applied identity only after
assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
assert.equal(after.state.appliedProfileId, 'work');
assert.equal(after.state.appliedServerId, 'a');
assert.deepEqual(after.state.appliedRouteRules, rules);
assert.deepEqual(after.state.appliedServerSnapshot, serverA);
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
@@ -197,11 +198,14 @@ test('activate only changes desired while stopped and transactionally switches w
});
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
const harness = createHarness({ gatewayDirect: true });
const state = initialState();
state.appliedRouteRules = [];
const harness = createHarness({ gatewayDirect: true, state });
await harness.service.apply('work', 'b');
const after = harness.snapshot();
assert.equal(after.state.desiredProfileId, 'work');
assert.equal(after.state.appliedProfileId, 'personal');
assert.deepEqual(after.state.appliedRouteRules, []);
assert.equal(harness.events.includes('config.write'), false);
assert.equal(harness.events.includes('runtime.start'), false);
});
@@ -232,6 +236,15 @@ test('restart uses applied pair while running and desired pair while stopped', a
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, rules);
});
test('restart in gateway-direct keeps user rules omitted from applied truth', async () => {
const state = initialState();
state.appliedRouteRules = [];
const harness = createHarness({ gatewayDirect: true, state });
await harness.service.restart();
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
});
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
+18 -2
View File
@@ -27,6 +27,10 @@ const storedProfile = (servers = [server]) => ({
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const routeRules = [
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
];
const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10,
profiles,
@@ -34,8 +38,8 @@ const canonicalState = (profiles = [storedProfile()]) => ({
appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null,
routeRules: [],
appliedRouteRules: [],
routeRules,
appliedRouteRules: routeRules,
routeRulesRevision: 0,
gatewayAutoEnabled: true,
});
@@ -277,6 +281,18 @@ test('gateway-auto startup writes candidate config before publication without ap
'state.update',
'route:gateway-direct',
]);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules);
});
test('gateway-auto running transitions publish the rules actually present in each mode', async () => {
const harness = createHarness({ running: true });
await harness.service.refresh();
assert.equal(harness.snapshot().gatewayAuto.mode, 'gateway-direct');
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
await harness.service.setEnabled(false);
assert.equal(harness.snapshot().gatewayAuto.mode, 'local-vpn');
assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules);
});
test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
+15 -1
View File
@@ -136,10 +136,17 @@ test('verified Gateway stays active through transient discovery failures', () =>
});
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
const desiredRules = [
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
];
const appliedRules = [...desiredRules].reverse();
const storedState = {
revision: 1,
subscriptionUrl,
gatewayAutoEnabled: true,
routeRules: desiredRules,
appliedRouteRules: appliedRules,
};
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
storedState: stored,
@@ -167,10 +174,17 @@ test('canonical route distinguishes fresh, stale, lost, disabled and local state
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
assert.equal(found.autoEnabled, true);
assert.equal(found.fallbackPreference, 'local-vpn');
assert.equal(found.rulesContractVersion, 2);
assert.deepEqual(found.localRules, desiredRules);
assert.deepEqual(found.activeLocalRules, []);
assert.equal(found.localRulesPendingRestart, false);
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
const local = snapshot(createGatewayAutoState());
assert.equal(local.reason, 'local');
assert.deepEqual(local.activeLocalRules, appliedRules);
assert.equal(local.localRulesPendingRestart, true);
});
test('client can ignore and restore a verified Gateway without losing discovery', () => {
+47 -26
View File
@@ -5,8 +5,8 @@ import test from 'node:test';
import { createRouteRulesService } from '../../dist/server/features/routing/index.js';
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true, outbound: 'direct' }];
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'vpn' }];
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({
id: 'primary',
@@ -78,7 +78,7 @@ function createHarness(overrides = {}) {
write: (value) => {
events.push('config.write');
if (failures.configWrite) throw failures.configWrite;
config = JSON.stringify(value);
config = JSON.stringify(value, null, 2);
if (failures.configWriteAfter) throw failures.configWriteAfter;
},
restore: (value) => {
@@ -103,6 +103,7 @@ function createHarness(overrides = {}) {
if (failures.runtimeRestore) throw failures.runtimeRestore;
},
},
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
serialize,
runOperation: async (operation) => {
events.push('operation');
@@ -130,27 +131,28 @@ function assertDomainRestored(actual, expected) {
test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => {
const harness = createHarness();
assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID');
assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID');
await assert.rejects(harness.service.update(oldRules, 1, undefined), (error) => error.code === 'STATE_CONFLICT');
assert.throws(() => harness.service.update(newRules, 2, undefined), (error) => error.code === 'REQUEST_INVALID');
assert.throws(() => harness.service.update('bad', 2, 2), (error) => error.code === 'REQUEST_INVALID');
assert.throws(() => harness.service.update(newRules, -1, 2), (error) => error.code === 'REQUEST_INVALID');
assert.throws(
() => harness.service.update([{ type: 'domain', value: 'example.com', enabled: true }], 2, 2),
(error) => error.code === 'REQUEST_INVALID',
);
await assert.rejects(harness.service.update(oldRules, 1, 2), (error) => error.code === 'STATE_CONFLICT');
const before = harness.snapshot();
await harness.service.update(oldRules, 2, undefined);
await harness.service.update(oldRules, 2, 2);
assert.deepEqual(harness.snapshot(), before);
assert.deepEqual(harness.events, []);
});
test('route rules support explicit domain revision and legacy global revision with normalization', async () => {
test('route rules use the explicit domain revision and normalize without losing target', async () => {
const explicit = createHarness();
await explicit.service.update([
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true },
{ type: 'domain_suffix', value: 'new.example', enabled: true },
], 2, undefined);
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
], 2, 2);
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
const legacy = createHarness();
await legacy.service.update(newRules, undefined, 10);
assert.deepEqual(legacy.snapshot().state.routeRules, newRules);
});
test('route rules state-only path leaves config and applied rules unchanged', async () => {
@@ -159,7 +161,7 @@ test('route rules state-only path leaves config and applied rules unchanged', as
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
]) {
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) });
await harness.service.update(newRules, 0, undefined);
await harness.service.update(newRules, 0, 2);
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
assert.equal(harness.snapshot().config, 'old-config');
@@ -169,16 +171,35 @@ test('route rules state-only path leaves config and applied rules unchanged', as
test('route rules running apply updates active rules while stopped leaves them pending', async () => {
const running = createHarness({ running: true });
await running.service.update(newRules, 2, undefined);
await running.service.update(newRules, 2, 2);
assert.deepEqual(running.snapshot().state.appliedRouteRules, newRules);
assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']);
const stopped = createHarness({ running: false });
await stopped.service.update(newRules, 2, undefined);
await stopped.service.update(newRules, 2, 2);
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules);
assert.equal(stopped.events.includes('runtime.apply'), false);
});
test('route rules in gateway-direct update desired order without touching config or runtime', async () => {
const state = { ...canonicalState(), appliedRouteRules: [] };
const harness = createHarness({ state, gatewayDirect: true, running: true });
await harness.service.update(newRules, 2, 2);
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
assert.equal(harness.snapshot().state.routeRulesRevision, 3);
assert.equal(harness.snapshot().config, 'old-config');
assert.deepEqual(harness.events, ['operation', 'state.update']);
});
test('route rules skip runtime apply when the generated config bytes are unchanged', async () => {
const config = JSON.stringify({ selectedServerId: 'server', routeRules: newRules }, null, 2);
const harness = createHarness({ running: true, config });
await harness.service.update(newRules, 2, 2);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, newRules);
assert.deepEqual(harness.events, ['operation', 'state.update']);
});
test('route rules rollback restores config/domain and honors runtime mutation phase', async () => {
for (const failures of [
{ configWriteAfter: new Error('config') },
@@ -186,18 +207,18 @@ test('route rules rollback restores config/domain and honors runtime mutation ph
]) {
const harness = createHarness({ failures });
const before = harness.snapshot();
await assert.rejects(harness.service.update(newRules, 2, undefined));
await assert.rejects(harness.service.update(newRules, 2, 2));
assertDomainRestored(harness.snapshot(), before);
}
const preMutation = new Error('invalid config');
const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } });
await assert.rejects(local.service.update(newRules, 2, undefined), (error) => error === preMutation);
await assert.rejects(local.service.update(newRules, 2, 2), (error) => error === preMutation);
assert.equal(local.events.includes('runtime.restore'), false);
const postMutation = new Error('remote failed');
const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } });
await assert.rejects(remote.service.update(newRules, 2, undefined), (error) => error === postMutation);
await assert.rejects(remote.service.update(newRules, 2, 2), (error) => error === postMutation);
assert.equal(remote.events.includes('runtime.restore'), true);
});
@@ -207,7 +228,7 @@ test('route rules rollback continues and classifies runtime restore failure', as
const aggregate = createHarness({
failures: { stateUpdates: [original], configRestore },
});
await assert.rejects(aggregate.service.update(newRules, 2, undefined), (error) => {
await assert.rejects(aggregate.service.update(newRules, 2, 2), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, configRestore]);
return true;
@@ -217,7 +238,7 @@ test('route rules rollback continues and classifies runtime restore failure', as
const broken = createHarness({
failures: { stateUpdates: [original], runtimeRestore },
});
await assert.rejects(broken.service.update(newRules, 2, undefined), (error) => {
await assert.rejects(broken.service.update(newRules, 2, 2), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
return true;
@@ -229,13 +250,13 @@ test('route rules route preserves one adapter and state-only response', async ()
const calls = [];
const route = createRouteRulesRoute({
routeRules: { update: async (...args) => { calls.push(args); } },
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, expectedRevision: 99 }),
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, rulesContractVersion: 2 }),
sendState: async () => { calls.push('sent'); },
});
const response = {};
assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false);
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules' }, response), true);
assert.deepEqual(calls, [[newRules, 3, 99], 'sent']);
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules/v2' }, response), true);
assert.deepEqual(calls, [[newRules, 3, 2], 'sent']);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createRouteRulesRoute\(\{/);
+11 -8
View File
@@ -21,12 +21,12 @@ const subscriptionConfig = {
}],
};
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
test('client exposes one local proxy and preserves mixed user-rule order before fallback', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: false },
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
{ type: 'domain_keyword', value: 'cdn', enabled: false, outbound: 'vpn' },
],
});
@@ -44,8 +44,8 @@ test('client exposes one local proxy and routes local exceptions before the sele
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ domain: ['api.example.com'], outbound: 'test-vpn' },
{ domain_suffix: ['example.com'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
@@ -55,7 +55,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,
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
routeRules: [
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
],
});
assert.deepEqual(config.route.rules, [
@@ -66,9 +69,9 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
timeout: '1s',
},
{ 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), ['test-vpn', 'direct']);
assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false);
});
+8 -3
View File
@@ -21,9 +21,13 @@ const subscriptionConfig = {
}],
};
test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
routeRules: [
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
{ type: 'domain_keyword', value: 'disabled', enabled: false, outbound: 'vpn' },
],
});
assert.deepEqual(config.route.rule_set, []);
@@ -47,7 +51,8 @@ test('gateway routes .ru domains directly and other traffic through the selected
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['api.example.com'], outbound: 'test-vpn' },
{ domain_suffix: ['example.com'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
+89 -3
View File
@@ -6,6 +6,9 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
const root = path.resolve(import.meta.dirname, '../..');
function listen(server, ...args) {
@@ -72,7 +75,7 @@ if (process.argv[2] === 'run') {
function profileState(server) {
return {
schemaVersion: 5,
schemaVersion: 6,
revision: 4,
profiles: [{
id: 'profile-a',
@@ -116,6 +119,8 @@ async function startClientFixture(t, {
state,
cacheContents,
config,
hostNetwork,
gatewayPresencePort,
}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory);
@@ -126,6 +131,8 @@ async function startClientFixture(t, {
if (config !== undefined) {
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config));
}
const hostNetworkPath = path.join(directory, 'host-network.json');
if (hostNetwork !== undefined) fs.writeFileSync(hostNetworkPath, JSON.stringify(hostNetwork));
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
@@ -136,7 +143,10 @@ async function startClientFixture(t, {
DATA_DIR: directory,
PORT: String(port),
PATH: `${binDirectory}:${process.env.PATH || ''}`,
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
HARBOR_HOST_NETWORK_STATE: hostNetwork === undefined
? path.join(directory, 'missing-network.json')
: hostNetworkPath,
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
HARBOR_TEST_RUN_MARKER: markerPath,
},
stdio: ['ignore', 'ignore', 'pipe'],
@@ -154,6 +164,82 @@ async function startClientFixture(t, {
};
}
function bootStateWithRules() {
const normalized = normalizeSubscriptionConfig({
outbounds: [{
type: 'vless',
tag: 'Boot VPN',
server: 'boot.example',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
}],
});
const server = normalized.servers[0];
const state = profileState(server);
state.profiles[0].subscriptionUrl = 'https://provider.example/0123456789abcdef0123456789abcdef';
state.profiles[0].subscriptionConfig = normalized.config;
state.routeRules = [
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
];
state.appliedRouteRules = [];
return state;
}
test('local-vpn boot promotes the exact desired ordered rules to applied truth', async (t) => {
const state = bootStateWithRules();
const fixture = await startClientFixture(t, { state });
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
assert.equal(fixture.state.route.mode, 'local-vpn');
assert.deepEqual(fixture.state.route.activeLocalRules, state.routeRules);
assert.equal(fixture.state.route.localRulesPendingRestart, false);
assert.deepEqual(config.route.rules.slice(2, 4), [
{ domain: ['api.example.com'], outbound: state.profiles[0].servers[0].id },
{ domain_suffix: ['example.com'], outbound: 'direct' },
]);
});
test('gateway-direct boot keeps the local proxy and diagnostics but omits every user rule', async (t) => {
const state = bootStateWithRules();
const gateway = http.createServer((req, res) => {
const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('nonce');
const payload = buildGatewayPresence({
appMode: 'gateway',
subscriptionUrl: state.profiles[0].subscriptionUrl,
gatewayId: 'gateway-test',
nonce,
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
});
const address = await listen(gateway, 0, '127.0.0.1');
t.after(() => close(gateway));
const fixture = await startClientFixture(t, {
state,
gatewayPresencePort: address.port,
hostNetwork: {
gateway: '127.0.0.1',
interface: 'lo0',
mac: 'aa:bb:cc:dd:ee:ff',
observedAt: new Date().toISOString(),
},
});
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
const stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8'));
assert.equal(fixture.state.route.mode, 'gateway-direct');
assert.deepEqual(fixture.state.route.activeLocalRules, []);
assert.equal(fixture.state.route.localRulesPendingRestart, false);
assert.deepEqual(stored.appliedRouteRules, []);
assert.deepEqual(config.inbounds.map(({ tag }) => tag), ['mixed-in', 'diagnostics-vpn-in']);
assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false);
assert.deepEqual(config.route.rules[1], {
inbound: ['diagnostics-vpn-in'],
outbound: state.profiles[0].servers[0].id,
});
});
test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
const staleServer = {
id: 'stale-server',
@@ -298,7 +384,7 @@ test('stopped Gateway boot explicitly stops an already running remote dataplane'
});
await listen(dataplane, socketPath);
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
schemaVersion: 5,
schemaVersion: 6,
revision: 3,
profiles: [],
desiredProfileId: '',
+45 -19
View File
@@ -90,6 +90,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot',
appliedServerSnapshot: snapshot.profiles[0].servers[0],
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(snapshot.route.rulesContractVersion, 2);
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
assert.throws(
() => assertStateSnapshot({ ...snapshot, revision: -1 }),
@@ -107,7 +108,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
server: '0.0.0.0',
server_port: 1,
};
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' }];
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl,
selectedTag: rejectedServer.tag,
@@ -296,14 +297,15 @@ setInterval(() => {}, 60_000);
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' },
]);
assert.equal(initial.route.rulesContractVersion, 2);
assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(migratedState.schemaVersion, 5);
assert.equal(migratedState.schemaVersion, 6);
assert.equal(migratedState.profiles.length, 1);
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
@@ -521,19 +523,20 @@ setInterval(() => {}, 60_000);
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
let routed = await mutation('/api/route-rules', 'PUT', {
let routed = await mutation('/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
rulesContractVersion: 2,
rules: [
{ 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 },
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' },
],
});
rulesRevision = routed.state.route.localRulesRevision;
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 },
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
]);
assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
@@ -545,16 +548,17 @@ setInterval(() => {}, 60_000);
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
{ domain: ['example.com'], outbound: 'direct' },
{ domain: ['example.com'], outbound: testServerId },
{ domain_suffix: ['example.org'], outbound: 'direct' },
]);
await mutation('/api/singbox/stop');
routed = await mutation('/api/route-rules', 'PUT', {
routed = await mutation('/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
rulesContractVersion: 2,
rules: [
...routed.state.route.localRules,
{ type: 'domain_keyword', value: 'media', enabled: true },
{ type: 'domain_keyword', value: 'media', enabled: true, outbound: 'vpn' },
],
});
rulesRevision = routed.state.route.localRulesRevision;
@@ -565,27 +569,48 @@ setInterval(() => {}, 60_000);
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules);
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
const invalidRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [{ type: 'domain_regex', value: '.*' }],
rulesContractVersion: 2,
rules: [{ type: 'domain_regex', value: '.*', enabled: true, outbound: 'direct' }],
});
assert.equal(invalidRules.response.status, 400);
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
assert.equal((await request(port, '/api/state')).revision, revision);
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
const staleRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
expectedRulesRevision: 0,
rulesContractVersion: 2,
rules: [],
});
assert.equal(staleRules.response.status, 409);
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
const skewConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRevision: revision,
rules: routed.state.route.localRules,
});
assert.equal(legacyNoop.response.status, 200);
assert.equal(legacyNoop.response.status, 400);
assert.equal(legacyNoop.payload.error.code, 'REQUEST_INVALID');
assert.equal((await request(port, '/api/state')).revision, revision);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig);
for (const rules of [
[{ type: 'domain', value: 'missing.example', enabled: true }],
[{ type: 'domain', value: 'unknown.example', enabled: true, outbound: 'block' }],
]) {
const rejected = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
rulesContractVersion: 2,
rules,
});
assert.equal(rejected.response.status, 400);
assert.equal(rejected.payload.error.code, 'REQUEST_INVALID');
assert.equal((await request(port, '/api/state')).revision, revision);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig);
}
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
@@ -599,9 +624,10 @@ process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(singboxPath, 0o755);
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
const failedRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [{ type: 'domain', value: 'broken.example' }],
rulesContractVersion: 2,
rules: [{ type: 'domain', value: 'broken.example', enabled: true, outbound: 'vpn' }],
});
assert.equal(failedRules.response.status, 422);
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
+56 -1
View File
@@ -61,7 +61,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' },
]);
const primary = migrated.profiles[0];
assert.equal(primary.label, 'Основной');
@@ -74,6 +74,61 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});
test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 5,
revision: 11,
routeRulesRevision: 7,
routeRules: [
{ type: 'domain', value: 'api.example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.com', enabled: false },
],
appliedRouteRules: [
{ type: 'domain_suffix', value: 'example.com', enabled: false },
{ type: 'domain', value: 'api.example.com', enabled: true },
],
};
const bytes = JSON.stringify(legacy);
fs.writeFileSync(filePath, bytes);
const store = createStateStore(filePath, {
now: () => new Date('2026-08-17T12:00:00.000Z'),
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, 6);
assert.equal(migrated.routeRulesRevision, 7);
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
['domain_suffix', 'direct'],
['domain', 'direct'],
]);
assert.equal(store.migration.fromVersion, 5);
assert.equal(store.migration.toVersion, 6);
assert.match(store.migration.backupPath, /\.backup-v5-/);
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
});
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
for (const [name, rule] of [
['missing', { type: 'domain', value: 'example.com', enabled: true }],
['unknown', { type: 'domain', value: 'example.com', enabled: true, outbound: 'block' }],
]) {
const filePath = `${fixture(t)}-${name}`;
const bytes = JSON.stringify({
schemaVersion: 6,
routeRules: [rule],
appliedRouteRules: [],
});
fs.writeFileSync(filePath, bytes);
const store = createStateStore(filePath);
assert.throws(() => store.read(), /outbound/);
assert.equal(fs.readFileSync(filePath, 'utf8'), bytes);
assert.equal(store.migration, null);
}
});
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, JSON.stringify({
+22 -3
View File
@@ -7,7 +7,7 @@ import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/s
const oldServer = { id: 'shared', label: 'Old', host: 'old.example', port: 443, protocol: 'vless' };
const nextServer = { id: 'next', label: 'Next', host: 'next.example', port: 443, protocol: 'vless' };
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }];
const profile = (id, label, server = oldServer) => ({
id,
@@ -47,7 +47,7 @@ function defaultState() {
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? defaultState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' };
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'local-vpn', gatewayId: '' });
let running = overrides.running ?? true;
let tail = Promise.resolve();
let updateCount = 0;
@@ -267,6 +267,25 @@ test('active refresh applies a retained server and keeps last-applied snapshot i
assert.equal(after.running, true);
});
test('active refresh publishes the route rules used by the rebuilt config', async () => {
const nextRules = [{ type: 'domain', value: 'vpn.example', enabled: true, outbound: 'vpn' }];
const state = defaultState();
state.routeRules = nextRules;
state.appliedRouteRules = routeRules;
const local = createHarness({ state, fetchSubscription: async () => parsed('personal', [oldServer]) });
await local.service.refreshProfile('personal', state.revision);
assert.deepEqual(local.snapshot().state.appliedRouteRules, nextRules);
assert.deepEqual(JSON.parse(local.snapshot().config).rules, nextRules);
const bypassed = createHarness({
state,
gatewayAuto: { mode: 'gateway-direct', gatewayId: 'gateway' },
fetchSubscription: async () => parsed('personal', [oldServer]),
});
await bypassed.service.refreshProfile('personal', state.revision);
assert.deepEqual(bypassed.snapshot().state.appliedRouteRules, []);
});
test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => {
const inactive = createHarness();
await inactive.service.deleteProfile('work', 'delete', 3);
@@ -294,7 +313,7 @@ test('deleting a pending desired profile leaves the running applied route untouc
const after = harness.snapshot();
assert.equal(after.config, 'old-config');
assert.equal(after.running, true);
assert.deepEqual(after.gatewayAuto, { mode: 'gateway-direct', gatewayId: 'gateway' });
assert.deepEqual(after.gatewayAuto, { mode: 'local-vpn', gatewayId: '' });
assert.equal(harness.calls.includes('gateway.set'), false);
});