From 97791443c01e68dfbc1073fe6d2049ff58d76155 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 30 Oct 2026 22:57:46 +0300 Subject: [PATCH] Add IP routing rules and fix gRPC buffer retention --- README.md | 6 +- package-lock.json | 18 ++--- package.json | 4 +- src/shared/contracts/state.ts | 5 +- src/shared/routingRules.ts | 26 +++++-- src/shared/versions.ts | 6 +- src/web/features/routing/RoutingFeature.tsx | 5 +- test/server/grpc-stream-memory.test.js | 84 +++++++++++++++++++++ test/server/harbor-storage.test.js | 18 +++++ test/server/route-rules-service.test.js | 6 +- test/server/singbox-client-mode.test.js | 4 + test/server/singbox-gateway-mode.test.js | 13 +++- test/server/state-contract.test.js | 6 +- test/shared/routing-rules.test.js | 35 +++++++++ test/web/style-boundaries.test.js | 4 +- 15 files changed, 210 insertions(+), 30 deletions(-) create mode 100644 test/server/grpc-stream-memory.test.js diff --git a/README.md b/README.md index ada0e26..55c08f3 100644 --- a/README.md +++ b/README.md @@ -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 Сначала посмотрите точное имя сетевого подключения: diff --git a/package-lock.json b/package-lock.json index a81f518..7918b90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 28a4756..5750dbe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts index 9941078..02999e1 100644 --- a/src/shared/contracts/state.ts +++ b/src/shared/contracts/state.ts @@ -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' && diff --git a/src/shared/routingRules.ts b/src/shared/routingRules.ts index 58192c3..bd00c1f 100644 --- a/src/shared/routingRules.ts +++ b/src/shared/routingRules.ts @@ -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 { diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f3a35e1..128169e 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -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 { diff --git a/src/web/features/routing/RoutingFeature.tsx b/src/web/features/routing/RoutingFeature.tsx index ca43935..315d24a 100644 --- a/src/web/features/routing/RoutingFeature.tsx +++ b/src/web/features/routing/RoutingFeature.tsx @@ -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 = { 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;

Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.

Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.

-

Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.

+

В доменное правило можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.

+

Для IP выберите «IP-адрес» и укажите один адрес IPv4 или IPv6 без порта. Для прямого подключения выберите стрелку и поместите правило выше VPN-правил.

diff --git a/test/server/grpc-stream-memory.test.js b/test/server/grpc-stream-memory.test.js new file mode 100644 index 0000000..114a6c9 --- /dev/null +++ b/test/server/grpc-stream-memory.test.js @@ -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()); +}); diff --git a/test/server/harbor-storage.test.js b/test/server/harbor-storage.test.js index 2f05881..bae58b6 100644 --- a/test/server/harbor-storage.test.js +++ b/test/server/harbor-storage.test.js @@ -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 })); diff --git a/test/server/route-rules-service.test.js b/test/server/route-rules-service.test.js index 07756e5..6c473e2 100644 --- a/test/server/route-rules-service.test.js +++ b/test/server/route-rules-service.test.js @@ -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); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index ecf7a29..4620cad 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -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' }, ], diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js index 1db6d9b..28e8d29 100644 --- a/test/server/singbox-gateway-mode.test.js +++ b/test/server/singbox-gateway-mode.test.js @@ -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({ diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index e42db63..0033c2e 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -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, diff --git a/test/shared/routing-rules.test.js b/test/shared/routing-rules.test.js index 3371f2c..4f77d12 100644 --- a/test/shared/routing-rules.test.js +++ b/test/shared/routing-rules.test.js @@ -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); + } +}); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index e8e5f05..5d14bf7 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -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);