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 список применяется.
|
Правила применяются только к трафику, который вошёл в 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 они сохраняются как желаемые, но локально не применяются.
|
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
|
||||||
|
|
||||||
|
Интерфейс и backend для IP-правил обновляются вместе; после обновления перезагрузите старую вкладку. Перед возвратом к сборке без поддержки IP-правил удалите их и успешно примените список, чтобы они исчезли и из сохранённых, и из применённых правил, либо восстановите резервную копию базы до добавления IP. Старые сборки отвергают неизвестный тип правила и не могут открыть такую базу.
|
||||||
|
|
||||||
## Системный прокси macOS
|
## Системный прокси macOS
|
||||||
|
|
||||||
Сначала посмотрите точное имя сетевого подключения:
|
Сначала посмотрите точное имя сетевого подключения:
|
||||||
|
|||||||
Generated
+9
-9
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "2.6.0",
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
"@connectrpc/connect": "2.0.3",
|
"@connectrpc/connect": "2.0.4",
|
||||||
"@connectrpc/connect-node": "2.0.3",
|
"@connectrpc/connect-node": "2.0.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -498,25 +498,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@connectrpc/connect": {
|
"node_modules/@connectrpc/connect": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.4.tgz",
|
||||||
"integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==",
|
"integrity": "sha512-E3EDUMHDdLKVYRxaGbb/MWImZUiGiPQgoehigCvy0NtMTiAn9AQIz/an1689b7mFbkMIXztPHwSNsvtqRPHhqQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@bufbuild/protobuf": "^2.2.0"
|
"@bufbuild/protobuf": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@connectrpc/connect-node": {
|
"node_modules/@connectrpc/connect-node": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.4.tgz",
|
||||||
"integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==",
|
"integrity": "sha512-+2rmWcCqYxh7TKv6zyJqGloUP5dZSkjrZ3AA1CljGBqiwH6pOjLoNX1ofKh9PBRkJnvfxvL3pBEEWaD1hJso4Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@bufbuild/protobuf": "^2.2.0",
|
"@bufbuild/protobuf": "^2.2.0",
|
||||||
"@connectrpc/connect": "2.0.3"
|
"@connectrpc/connect": "2.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/selector-specificity": {
|
"node_modules/@csstools/selector-specificity": {
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "2.6.0",
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
"@connectrpc/connect": "2.0.3",
|
"@connectrpc/connect": "2.0.4",
|
||||||
"@connectrpc/connect-node": "2.0.3",
|
"@connectrpc/connect-node": "2.0.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
normalizeRouteRules,
|
normalizeRouteRules,
|
||||||
ROUTE_RULES_CONTRACT_VERSION,
|
ROUTE_RULES_CONTRACT_VERSION,
|
||||||
type RouteRuleOutbound,
|
type RouteRuleOutbound,
|
||||||
|
type RouteRuleType,
|
||||||
} from '../routingRules.js';
|
} from '../routingRules.js';
|
||||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +38,7 @@ export interface HarborServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteRule {
|
export interface RouteRule {
|
||||||
type: 'domain' | 'domain_suffix' | 'domain_keyword';
|
type: RouteRuleType;
|
||||||
value: string;
|
value: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
outbound: RouteRuleOutbound;
|
outbound: RouteRuleOutbound;
|
||||||
@@ -492,7 +493,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
);
|
);
|
||||||
const validRouteRule = (rule: RouteRule) => (
|
const validRouteRule = (rule: RouteRule) => (
|
||||||
rule &&
|
rule &&
|
||||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
['domain', 'domain_suffix', 'domain_keyword', 'ip_cidr'].includes(rule.type) &&
|
||||||
typeof rule.value === 'string' &&
|
typeof rule.value === 'string' &&
|
||||||
Boolean(rule.value) &&
|
Boolean(rule.value) &&
|
||||||
typeof rule.enabled === 'boolean' &&
|
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' }),
|
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']);
|
const RULE_OUTBOUNDS = new Set(['vpn', 'direct']);
|
||||||
export const ROUTE_RULES_CONTRACT_VERSION = 2;
|
export const ROUTE_RULES_CONTRACT_VERSION = 2;
|
||||||
export const MAX_ROUTE_RULES = 200;
|
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 type RouteRuleOutbound = 'vpn' | 'direct';
|
||||||
|
|
||||||
export interface NormalizedRouteRule {
|
export interface NormalizedRouteRule {
|
||||||
@@ -32,6 +32,18 @@ function hostname(value: unknown) {
|
|||||||
return normalized;
|
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 {
|
function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
|
||||||
const rule = record(input);
|
const rule = record(input);
|
||||||
const type = String(rule.type || '').trim();
|
const type = String(rule.type || '').trim();
|
||||||
@@ -46,10 +58,12 @@ function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
|
|||||||
? String(rule.outbound || '').trim()
|
? String(rule.outbound || '').trim()
|
||||||
: 'direct';
|
: 'direct';
|
||||||
if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound');
|
if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound');
|
||||||
const value = type === 'domain_keyword'
|
const value = type === 'ip_cidr'
|
||||||
|
? ipAddress(rule.value)
|
||||||
|
: type === 'domain_keyword'
|
||||||
? String(rule.value || '').trim().toLowerCase()
|
? String(rule.value || '').trim().toLowerCase()
|
||||||
: hostname(rule.value);
|
: hostname(rule.value);
|
||||||
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
if (type !== 'ip_cidr' && (!value || value.length > 253 || /[\s/:?#]/.test(value))) {
|
||||||
throw new TypeError('Invalid domain rule value');
|
throw new TypeError('Invalid domain rule value');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.37.2',
|
macClient: '0.38.0',
|
||||||
gatewayClient: '0.39.2',
|
gatewayClient: '0.40.0',
|
||||||
gatewayBackend: '0.39.1',
|
gatewayBackend: '0.40.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
|||||||
['domain', 'Точный домен'],
|
['domain', 'Точный домен'],
|
||||||
['domain_suffix', 'Суффикс'],
|
['domain_suffix', 'Суффикс'],
|
||||||
['domain_keyword', 'Содержит'],
|
['domain_keyword', 'Содержит'],
|
||||||
|
['ip_cidr', 'IP-адрес'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
|
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
|
||||||
domain: 'example.com или полный URL',
|
domain: 'example.com или полный URL',
|
||||||
domain_suffix: 'example.org',
|
domain_suffix: 'example.org',
|
||||||
domain_keyword: 'cdn',
|
domain_keyword: 'cdn',
|
||||||
|
ip_cidr: '203.0.113.25 или 2001:db8::1',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DraftRule extends RouteRule {
|
interface DraftRule extends RouteRule {
|
||||||
@@ -926,7 +928,8 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
|||||||
<div className="client-local-rules-help-copy">
|
<div className="client-local-rules-help-copy">
|
||||||
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.</p>
|
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.</p>
|
||||||
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</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>
|
</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]);
|
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) => {
|
test('failed import leaves no partial documents and can be retried after fixing the original', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ revision: 9 }));
|
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';
|
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
|
||||||
|
|
||||||
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true, outbound: 'direct' }];
|
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 server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||||
const storedProfile = (servers = [server]) => ({
|
const storedProfile = (servers = [server]) => ({
|
||||||
id: 'primary',
|
id: 'primary',
|
||||||
@@ -153,6 +156,7 @@ test('route rules use the explicit domain revision and normalize without losing
|
|||||||
await explicit.service.update([
|
await explicit.service.update([
|
||||||
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
|
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
|
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
|
||||||
|
{ type: 'ip_cidr', value: ' 203.0.113.25 ', enabled: true, outbound: 'direct' },
|
||||||
], 2, 2);
|
], 2, 2);
|
||||||
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
|
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
|
||||||
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
|
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', () => {
|
test('client exposes one local proxy and preserves mixed user-rule order before fallback', () => {
|
||||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||||
routeRules: [
|
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', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||||
{ type: 'domain_keyword', value: 'cdn', enabled: false, outbound: 'vpn' },
|
{ 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',
|
timeout: '1s',
|
||||||
},
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||||
|
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
|
||||||
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
||||||
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
||||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
{ 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', {
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||||
clientDirect: true,
|
clientDirect: true,
|
||||||
routeRules: [
|
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', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
{ 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', () => {
|
test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
|
||||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||||
routeRules: [
|
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', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||||
{ type: 'domain_keyword', value: 'disabled', enabled: false, outbound: 'vpn' },
|
{ 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',
|
timeout: '1s',
|
||||||
},
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||||
|
{ ip_cidr: ['2001:db8::1'], outbound: 'direct' },
|
||||||
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
||||||
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
||||||
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
{ 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' },
|
primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' },
|
||||||
reserve: { subscriptionConfig: reserveConfig, 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), [
|
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',
|
default: 'channel-primary',
|
||||||
interrupt_exist_connections: false,
|
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-primary-in'], outbound: 'channel-primary' },
|
||||||
{ inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' },
|
{ inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' },
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'channel-selector' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'channel-selector' },
|
||||||
|
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
|
||||||
{ domain: ['api.example.com'], outbound: 'channel-selector' },
|
{ domain: ['api.example.com'], outbound: 'channel-selector' },
|
||||||
|
{ ip_cidr: ['2001:db8::1'], outbound: 'channel-selector' },
|
||||||
]);
|
]);
|
||||||
assert.equal(config.route.final, 'channel-selector');
|
assert.equal(config.route.final, 'channel-selector');
|
||||||
const restoredReserve = buildDualChannelGatewayConfig({
|
const restoredReserve = buildDualChannelGatewayConfig({
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ setInterval(() => {}, 60_000);
|
|||||||
rulesContractVersion: 2,
|
rulesContractVersion: 2,
|
||||||
rules: [
|
rules: [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
|
{ 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', value: 'https://Example.com/private?q=1', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' },
|
{ type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' },
|
||||||
],
|
],
|
||||||
@@ -541,12 +542,13 @@ setInterval(() => {}, 60_000);
|
|||||||
rulesRevision = routed.state.route.localRulesRevision;
|
rulesRevision = routed.state.route.localRulesRevision;
|
||||||
assert.deepEqual(routed.state.route.localRules, [
|
assert.deepEqual(routed.state.route.localRules, [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
|
{ 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', value: 'example.com', enabled: true, outbound: 'vpn' },
|
||||||
{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
|
{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
|
||||||
]);
|
]);
|
||||||
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
||||||
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
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'],
|
inbound: ['mixed-in', 'diagnostics-vpn-in'],
|
||||||
action: 'sniff',
|
action: 'sniff',
|
||||||
@@ -554,6 +556,7 @@ setInterval(() => {}, 60_000);
|
|||||||
timeout: '1s',
|
timeout: '1s',
|
||||||
},
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
||||||
|
{ ip_cidr: ['203.0.113.25'], outbound: 'direct' },
|
||||||
{ domain: ['example.com'], outbound: testServerId },
|
{ domain: ['example.com'], outbound: testServerId },
|
||||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||||
]);
|
]);
|
||||||
@@ -606,6 +609,7 @@ setInterval(() => {}, 60_000);
|
|||||||
for (const rules of [
|
for (const rules of [
|
||||||
[{ type: 'domain', value: 'missing.example', enabled: true }],
|
[{ type: 'domain', value: 'missing.example', enabled: true }],
|
||||||
[{ type: 'domain', value: 'unknown.example', enabled: true, outbound: 'block' }],
|
[{ 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', {
|
const rejected = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
|
||||||
expectedRulesRevision: rulesRevision,
|
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' },
|
{ 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',
|
ruleDeclarationSequences: '7659444a0a4871d15b7702f886c3730f02ea8fdfa54dc5659465ecc4cd1743a6',
|
||||||
selectors: 'e15851f9c14afb747e8c7d5969586d1c2193305dbd9ddc809020eae87d2514af',
|
selectors: 'e15851f9c14afb747e8c7d5969586d1c2193305dbd9ddc809020eae87d2514af',
|
||||||
variableReferences: '3f5340864200e324948e4387a4bb534e82732ed91bc4e3958081890cf7bb854e',
|
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', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
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);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
|
|||||||
Reference in New Issue
Block a user