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

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