Add IP routing rules and fix gRPC buffer retention
This commit is contained in:
@@ -149,7 +149,9 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
||||
|
||||
## Правила маршрутизации
|
||||
|
||||
После добавления подписки откройте «Правила маршрутизации» справа от основного экрана. При первом обновлении Harbor добавит включённое правило `*.ru → Напрямую`. Для каждого точного домена, suffix или фрагмента имени можно выбрать результат `VPN` либо `Напрямую`, выключить правило или удалить его. Правила проверяются сверху вниз, первое совпадение выбирает маршрут. Чтобы изменить порядок, возьмите строку за три точки слева и перетащите; с клавиатуры нажмите на этом хвате `Space` или `Enter`, переместите правило стрелками и повторно нажмите для размещения.
|
||||
После добавления подписки откройте «Правила маршрутизации» справа от основного экрана. При первом обновлении Harbor добавит включённое правило `*.ru → Напрямую`. Для каждого точного домена, suffix, фрагмента имени или IP-адреса можно выбрать результат `VPN` либо `Напрямую`, выключить правило или удалить его. Правила проверяются сверху вниз, первое совпадение выбирает маршрут. Чтобы изменить порядок, возьмите строку за три точки слева и перетащите; с клавиатуры нажмите на этом хвате `Space` или `Enter`, переместите правило стрелками и повторно нажмите для размещения.
|
||||
|
||||
Чтобы направить соединения к своему серверу напрямую, добавьте правило типа «IP-адрес», укажите один IPv4 или IPv6, например `203.0.113.25` или `2001:db8::1`, и выберите стрелку «Напрямую». Поместите его выше более общих VPN-правил. Для следующего адреса добавьте отдельную строку. Адрес указывают без порта, URL, квадратных скобок или маски сети. Правило проверяет известный IP назначения соединения независимо от порта и протокола; оно не задаёт маршрут для устройства-источника.
|
||||
|
||||
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения. Для устройства Gateway с маршрутом `VPN` и при обычном локальном VPN список применяется.
|
||||
|
||||
@@ -157,6 +159,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
||||
|
||||
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
|
||||
|
||||
Интерфейс и backend для IP-правил обновляются вместе; после обновления перезагрузите старую вкладку. Перед возвратом к сборке без поддержки IP-правил удалите их и успешно примените список, чтобы они исчезли и из сохранённых, и из применённых правил, либо восстановите резервную копию базы до добавления IP. Старые сборки отвергают неизвестный тип правила и не могут открыть такую базу.
|
||||
|
||||
## Системный прокси macOS
|
||||
|
||||
Сначала посмотрите точное имя сетевого подключения:
|
||||
|
||||
Generated
+9
-9
@@ -9,8 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0",
|
||||
"@connectrpc/connect": "2.0.3",
|
||||
"@connectrpc/connect-node": "2.0.3",
|
||||
"@connectrpc/connect": "2.0.4",
|
||||
"@connectrpc/connect-node": "2.0.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -498,25 +498,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@connectrpc/connect": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz",
|
||||
"integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.4.tgz",
|
||||
"integrity": "sha512-E3EDUMHDdLKVYRxaGbb/MWImZUiGiPQgoehigCvy0NtMTiAn9AQIz/an1689b7mFbkMIXztPHwSNsvtqRPHhqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@connectrpc/connect-node": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz",
|
||||
"integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.4.tgz",
|
||||
"integrity": "sha512-+2rmWcCqYxh7TKv6zyJqGloUP5dZSkjrZ3AA1CljGBqiwH6pOjLoNX1ofKh9PBRkJnvfxvL3pBEEWaD1hJso4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.0",
|
||||
"@connectrpc/connect": "2.0.3"
|
||||
"@connectrpc/connect": "2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/selector-specificity": {
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0",
|
||||
"@connectrpc/connect": "2.0.3",
|
||||
"@connectrpc/connect-node": "2.0.3",
|
||||
"@connectrpc/connect": "2.0.4",
|
||||
"@connectrpc/connect-node": "2.0.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeRouteRules,
|
||||
ROUTE_RULES_CONTRACT_VERSION,
|
||||
type RouteRuleOutbound,
|
||||
type RouteRuleType,
|
||||
} from '../routingRules.js';
|
||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||
import {
|
||||
@@ -37,7 +38,7 @@ export interface HarborServer {
|
||||
}
|
||||
|
||||
export interface RouteRule {
|
||||
type: 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
type: RouteRuleType;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
outbound: RouteRuleOutbound;
|
||||
@@ -492,7 +493,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
);
|
||||
const validRouteRule = (rule: RouteRule) => (
|
||||
rule &&
|
||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||
['domain', 'domain_suffix', 'domain_keyword', 'ip_cidr'].includes(rule.type) &&
|
||||
typeof rule.value === 'string' &&
|
||||
Boolean(rule.value) &&
|
||||
typeof rule.enabled === 'boolean' &&
|
||||
|
||||
@@ -2,12 +2,12 @@ export const INITIAL_ROUTE_RULES = Object.freeze([
|
||||
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }),
|
||||
]);
|
||||
|
||||
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
||||
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword', 'ip_cidr']);
|
||||
const RULE_OUTBOUNDS = new Set(['vpn', 'direct']);
|
||||
export const ROUTE_RULES_CONTRACT_VERSION = 2;
|
||||
export const MAX_ROUTE_RULES = 200;
|
||||
|
||||
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword' | 'ip_cidr';
|
||||
export type RouteRuleOutbound = 'vpn' | 'direct';
|
||||
|
||||
export interface NormalizedRouteRule {
|
||||
@@ -32,6 +32,18 @@ function hostname(value: unknown) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ipAddress(value: unknown) {
|
||||
const input = String(value || '').trim();
|
||||
if (input.includes(':') && /^[\da-f:.]+$/i.test(input)) {
|
||||
return new URL(`http://[${input}]/`).hostname.slice(1, -1);
|
||||
}
|
||||
if (!/^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(input)
|
||||
|| input.split('.').some((part) => Number(part) > 255)) {
|
||||
throw new TypeError('Invalid IP rule value');
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
|
||||
const rule = record(input);
|
||||
const type = String(rule.type || '').trim();
|
||||
@@ -46,10 +58,12 @@ function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
|
||||
? String(rule.outbound || '').trim()
|
||||
: 'direct';
|
||||
if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound');
|
||||
const value = type === 'domain_keyword'
|
||||
? String(rule.value || '').trim().toLowerCase()
|
||||
: hostname(rule.value);
|
||||
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
||||
const value = type === 'ip_cidr'
|
||||
? ipAddress(rule.value)
|
||||
: type === 'domain_keyword'
|
||||
? String(rule.value || '').trim().toLowerCase()
|
||||
: hostname(rule.value);
|
||||
if (type !== 'ip_cidr' && (!value || value.length > 253 || /[\s/:?#]/.test(value))) {
|
||||
throw new TypeError('Invalid domain rule value');
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.37.2',
|
||||
gatewayClient: '0.39.2',
|
||||
gatewayBackend: '0.39.1',
|
||||
macClient: '0.38.0',
|
||||
gatewayClient: '0.40.0',
|
||||
gatewayBackend: '0.40.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -40,12 +40,14 @@ const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
||||
['domain', 'Точный домен'],
|
||||
['domain_suffix', 'Суффикс'],
|
||||
['domain_keyword', 'Содержит'],
|
||||
['ip_cidr', 'IP-адрес'],
|
||||
];
|
||||
|
||||
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
|
||||
domain: 'example.com или полный URL',
|
||||
domain_suffix: 'example.org',
|
||||
domain_keyword: 'cdn',
|
||||
ip_cidr: '203.0.113.25 или 2001:db8::1',
|
||||
};
|
||||
|
||||
interface DraftRule extends RouteRule {
|
||||
@@ -926,7 +928,8 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
<div className="client-local-rules-help-copy">
|
||||
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.</p>
|
||||
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
|
||||
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
|
||||
<p>В доменное правило можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
|
||||
<p>Для IP выберите «IP-адрес» и укажите один адрес IPv4 или IPv6 без порта. Для прямого подключения выберите стрелку и поместите правило выше VPN-правил.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user