Add IP routing rules and fix gRPC buffer retention
This commit is contained in:
@@ -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());
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user