Add IP routing rules and fix gRPC buffer retention
Build and Deploy Gateway / build-and-push (push) Successful in 40s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-10-30 22:57:46 +03:00
parent 4c58384056
commit 97791443c0
15 changed files with 210 additions and 30 deletions
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import test from 'node:test';
test('gRPC streaming releases processed buffers while the stream stays open', (t) => {
const result = spawnSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', `
import assert from 'node:assert/strict';
import { create } from '@bufbuild/protobuf';
import { createClient } from '@connectrpc/connect';
import { connectNodeAdapter, createGrpcTransport } from '@connectrpc/connect-node';
import http2 from 'node:http2';
import { setImmediate as tick } from 'node:timers/promises';
import { ConnectionEventsSchema, StartedService } from './dist/server/generated/daemon/started_service_pb.js';
const count = 512;
const payload = 'x'.repeat(16 * 1024);
const sessions = new Set();
const server = http2.createServer(connectNodeAdapter({
routes(router) {
router.service(StartedService, {
async *subscribeConnections(_request, context) {
for (let i = 0; i < count; i++) {
yield create(ConnectionEventsSchema, {
events: [{ id: String(i), connection: { id: String(i), rule: payload } }],
});
}
await new Promise((resolve) => {
if (context.signal.aborted) resolve();
else context.signal.addEventListener('abort', resolve, { once: true });
});
},
});
},
}));
server.on('session', (session) => {
sessions.add(session);
session.once('close', () => sessions.delete(session));
});
const controller = new AbortController();
async function collectedBuffers() {
for (let i = 0; i < 3; i++) {
await tick();
global.gc();
}
return process.memoryUsage().arrayBuffers;
}
try {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const client = createClient(StartedService, createGrpcTransport({
baseUrl: 'http://127.0.0.1:' + server.address().port,
}));
const baseline = await collectedBuffers();
const iterator = client.subscribeConnections({ interval: 1n }, {
signal: controller.signal,
})[Symbol.asyncIterator]();
for (let i = 0; i < count; i++) {
const next = await iterator.next();
assert.equal(next.done, false);
assert.equal(next.value.events[0].id, String(i));
assert.equal(next.value.events[0].connection.rule, payload);
}
const retainedBytes = await collectedBuffers() - baseline;
console.log(JSON.stringify({ processedBytes: count * payload.length, retainedBytes }));
assert.equal(controller.signal.aborted, false);
assert.ok(retainedBytes < 2 * 1024 * 1024,
'processed gRPC buffers remain retained while the stream is open: ' + retainedBytes);
} finally {
controller.abort();
for (const session of sessions) session.destroy();
if (server.listening) await new Promise((resolve) => server.close(resolve));
}
`], {
cwd: path.resolve(import.meta.dirname, '../..'),
encoding: 'utf8',
timeout: 20_000,
});
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr + result.stdout);
t.diagnostic(result.stdout.trim());
});
+18
View File
@@ -52,6 +52,24 @@ test('atomic import preserves revisions, rules, device checkpoints and journal;
assert.equal(fs.readFileSync(path.join(f.directory, 'activity-journal.json'), 'utf8'), originals[2]);
});
test('saved and applied IP rules retain values, order and revision across SQLite reopen', (t) => {
const f = fixture(t);
const rules = [
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, outbound: 'direct' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'vpn' },
{ type: 'ip_cidr', value: '2001:db8::1', enabled: false, outbound: 'vpn' },
];
let store = f.open();
store.state.update((state) => ({
...state, routeRules: rules, appliedRouteRules: rules, routeRulesRevision: 7,
}));
store.close();
store = f.open();
assert.deepEqual(store.state.read().routeRules, rules);
assert.deepEqual(store.state.read().appliedRouteRules, rules);
assert.equal(store.state.read().routeRulesRevision, 7);
});
test('failed import leaves no partial documents and can be retried after fixing the original', (t) => {
const f = fixture(t);
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ revision: 9 }));
+5 -1
View File
@@ -6,7 +6,10 @@ import { createRouteRulesService } from '../../dist/server/features/routing/inde
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
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 newRules = [
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'vpn' },
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, outbound: 'direct' },
];
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({
id: 'primary',
@@ -153,6 +156,7 @@ test('route rules use the explicit domain revision and normalize without losing
await explicit.service.update([
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
{ type: 'ip_cidr', value: ' 203.0.113.25 ', enabled: true, outbound: 'direct' },
], 2, 2);
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
+4
View File
@@ -24,9 +24,11 @@ const subscriptionConfig = {
test('client exposes one local proxy and preserves mixed user-rule order before fallback', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, outbound: 'direct' },
{ 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' },
{ type: 'ip_cidr', value: '2001:db8::1', enabled: false, outbound: 'direct' },
],
});
@@ -44,6 +46,7 @@ test('client exposes one local proxy and preserves mixed user-rule order before
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
{ domain: ['api.example.com'], outbound: 'test-vpn' },
{ domain_suffix: ['example.com'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
@@ -56,6 +59,7 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
clientDirect: true,
routeRules: [
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, outbound: 'direct' },
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
],
+11 -2
View File
@@ -46,9 +46,11 @@ const subscriptionConfig = {
test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'ip_cidr', value: '2001:0DB8:0:0::1', enabled: true, outbound: 'direct' },
{ 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' },
{ type: 'ip_cidr', value: '203.0.113.25', enabled: false, outbound: 'direct' },
],
});
@@ -73,6 +75,7 @@ test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ ip_cidr: ['2001:db8::1'], outbound: 'direct' },
{ domain: ['api.example.com'], outbound: 'test-vpn' },
{ domain_suffix: ['example.com'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
@@ -91,7 +94,11 @@ test('gateway dual-channel config fixes probes to role tags and keeps inbound co
primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' },
reserve: { subscriptionConfig: reserveConfig, selectedServerId: 'same-provider-tag' },
}, {
routeRules: [{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }],
routeRules: [
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, outbound: 'direct' },
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'ip_cidr', value: '2001:db8::1', enabled: true, outbound: 'vpn' },
],
});
assert.deepEqual(config.outbounds.map(({ tag }) => tag), [
@@ -104,11 +111,13 @@ test('gateway dual-channel config fixes probes to role tags and keeps inbound co
default: 'channel-primary',
interrupt_exist_connections: false,
});
assert.deepEqual(config.route.rules.slice(1, 5), [
assert.deepEqual(config.route.rules.slice(1, 7), [
{ inbound: ['diagnostics-primary-in'], outbound: 'channel-primary' },
{ inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' },
{ inbound: ['diagnostics-vpn-in'], outbound: 'channel-selector' },
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
{ domain: ['api.example.com'], outbound: 'channel-selector' },
{ ip_cidr: ['2001:db8::1'], outbound: 'channel-selector' },
]);
assert.equal(config.route.final, 'channel-selector');
const restoredReserve = buildDualChannelGatewayConfig({
+5 -1
View File
@@ -534,6 +534,7 @@ setInterval(() => {}, 60_000);
rulesContractVersion: 2,
rules: [
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'ip_cidr', value: ' 203.0.113.25 ', enabled: true, 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' },
],
@@ -541,12 +542,13 @@ setInterval(() => {}, 60_000);
rulesRevision = routed.state.route.localRulesRevision;
assert.deepEqual(routed.state.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'ip_cidr', value: '203.0.113.25', enabled: true, 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);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 4), [
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 5), [
{
inbound: ['mixed-in', 'diagnostics-vpn-in'],
action: 'sniff',
@@ -554,6 +556,7 @@ setInterval(() => {}, 60_000);
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
{ domain: ['example.com'], outbound: testServerId },
{ domain_suffix: ['example.org'], outbound: 'direct' },
]);
@@ -606,6 +609,7 @@ setInterval(() => {}, 60_000);
for (const rules of [
[{ type: 'domain', value: 'missing.example', enabled: true }],
[{ type: 'domain', value: 'unknown.example', enabled: true, outbound: 'block' }],
[{ type: 'ip_cidr', value: '256.0.0.1', enabled: true, outbound: 'direct' }],
]) {
const rejected = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision,
+35
View File
@@ -49,3 +49,38 @@ test('legacy rules default to direct while order and first duplicate stay canoni
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
]);
});
test('IP rules normalize exact IPv4 and IPv6 destinations and deduplicate equivalent IPv6', () => {
const rule = (value, outbound = 'direct') => ({ type: 'ip_cidr', value, enabled: true, outbound });
assert.deepEqual(normalizeRouteRules([
rule(' 203.0.113.25 '),
rule('2001:0DB8:0:0:0:0:0:1', 'vpn'),
rule('2001:db8::1'),
rule('::ffff:192.0.2.128'),
rule('0.0.0.0'),
rule('255.255.255.255'),
rule('::'),
], { strict: true }), [
rule('203.0.113.25'),
rule('2001:db8::1', 'vpn'),
rule('::ffff:c000:280'),
rule('0.0.0.0'),
rule('255.255.255.255'),
rule('::'),
]);
});
test('IP rules reject domains, URLs, ports, networks and ambiguous or invalid addresses', () => {
for (const value of [
'', 'example.com', 'https://203.0.113.25', '203.0.113.25:443',
'203.0.113.25/32', '2001:db8::/64', '203.0.113', '127.1', '2130706433',
'0x7f.0.0.1', '203.0.113.025', '256.0.0.1', '203.0.113.-1', '1.2.3.4.5',
'1.2.3. 4', '1.2.3.4,5.6.7.8', '1.2.3.4\n5.6.7.8',
'[2001:db8::1]', '[2001:db8::1]:443', 'fe80::1%en0', '2001:db8:::1',
'1:2:3:4:5:6:7', '1:2:3:4:5:6:7:8:9', '2001:db8::gg', '::ffff:256.0.0.1',
]) {
assert.throws(() => normalizeRouteRules([
{ type: 'ip_cidr', value, outbound: 'direct' },
], { strict: true }), TypeError, value);
}
});
+2 -2
View File
@@ -59,7 +59,7 @@ const acceptedLedger = {
ruleDeclarationSequences: '7659444a0a4871d15b7702f886c3730f02ea8fdfa54dc5659465ecc4cd1743a6',
selectors: 'e15851f9c14afb747e8c7d5969586d1c2193305dbd9ddc809020eae87d2514af',
variableReferences: '3f5340864200e324948e4387a4bb534e82732ed91bc4e3958081890cf7bb854e',
witnesses: '27f96ddab7b128a8924c7a733244523fe007a34ba91d192f63ba19a50c38d4a5',
witnesses: '771d9a3d34fb99e36714a36926dc85eb2ab51b56e59348195bd0a59373f00f0c',
},
};
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 1463);
assert.equal(witnesses.length, 1464);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);