Add local routing rules to Harbor
This commit is contained in:
@@ -121,6 +121,12 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \
|
|||||||
|
|
||||||
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
|
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
|
||||||
|
|
||||||
|
## Локальные правила маршрутизации
|
||||||
|
|
||||||
|
После добавления подписки откройте «Локальные правила» справа от основного экрана. Встроенное правило `*.ru` всегда отправляет российские домены напрямую. Дополнительно можно добавить точный домен, suffix домена или фрагмент имени; эти правила также обходят VPN, а остальной трафик идёт через выбранный сервер.
|
||||||
|
|
||||||
|
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
||||||
|
|
||||||
## Системный прокси macOS
|
## Системный прокси macOS
|
||||||
|
|
||||||
Сначала посмотрите точное имя сетевого подключения:
|
Сначала посмотрите точное имя сетевого подключения:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Harbor state recovery
|
# Harbor state recovery
|
||||||
|
|
||||||
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 1`; subscription cache, generated sing-box config and HWID keep their existing filenames.
|
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 2`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 adds locally managed domain routing rules; an absent field is migrated to an empty custom list while the built-in `.ru` rule remains in code.
|
||||||
|
|
||||||
## Atomic writes
|
## Atomic writes
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ Persistent files are written to a unique temporary file in the same directory, f
|
|||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
On startup, a legacy `state.json` without `schemaVersion` is normalized and migrated to v1. Before replacement Harbor saves the original beside it:
|
On startup, a legacy `state.json` without `schemaVersion`, or a v1 state without local routing rules, is normalized and migrated to the current schema. Before replacement Harbor saves the original beside it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
state.json.backup-v0-2026-07-11T12-00-00-000Z
|
state.json.backup-v0-2026-07-11T12-00-00-000Z
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
withStateV0Compatibility,
|
withStateV0Compatibility,
|
||||||
} from '../shared/contracts/state.js';
|
} from '../shared/contracts/state.js';
|
||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
|
import { normalizeRouteRules } from '../shared/routingRules.js';
|
||||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
|
|
||||||
@@ -192,9 +193,14 @@ function subscriptionHost(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActiveConfig(subscriptionConfig, selectedTag) {
|
function buildActiveConfig(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedTag,
|
||||||
|
routeRules = stateStore.read().routeRules,
|
||||||
|
) {
|
||||||
return buildGatewayConfig(subscriptionConfig, selectedTag, {
|
return buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||||
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
||||||
|
routeRules,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +389,38 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyRouteRules(routeRules) {
|
||||||
|
const state = normalizeStoredState(stateStore.read());
|
||||||
|
const cached = readSubscriptionCache();
|
||||||
|
if (!state.selectedTag || !cached?.config) {
|
||||||
|
updateStoredState((current) => ({ ...current, routeRules }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null;
|
||||||
|
const wasRunning = singboxRuntime.running;
|
||||||
|
try {
|
||||||
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
|
||||||
|
if (wasRunning) await startSingbox();
|
||||||
|
updateStoredState((current) => ({ ...current, routeRules }));
|
||||||
|
} catch (error) {
|
||||||
|
if (previousConfig === null) removeSingboxConfig();
|
||||||
|
else restoreSingboxConfig(previousConfig);
|
||||||
|
if (wasRunning) {
|
||||||
|
try {
|
||||||
|
await startSingbox();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
throw new HarborError('PROCESS_START_FAILED', {
|
||||||
|
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshSavedSubscription() {
|
function refreshSavedSubscription() {
|
||||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||||
|
|
||||||
@@ -515,6 +553,7 @@ async function handleApi(req, res) {
|
|||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
|
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
|
||||||
updateStoredState((state) => ({
|
updateStoredState((state) => ({
|
||||||
|
routeRules: state.routeRules,
|
||||||
subscriptionUrl: normalizedUrl,
|
subscriptionUrl: normalizedUrl,
|
||||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||||
servers: result.servers,
|
servers: result.servers,
|
||||||
@@ -564,12 +603,32 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
||||||
|
const { rules, expectedRevision } = await readBody(req);
|
||||||
|
let routeRules;
|
||||||
|
try {
|
||||||
|
routeRules = normalizeRouteRules(rules, { strict: true });
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
await serializeControl(async () => {
|
||||||
|
const current = normalizeStoredState(stateStore.read());
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||||
|
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
||||||
|
});
|
||||||
|
return sendState(res);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
await withOperation('subscription-forget', () => serializeControl(async () => {
|
await withOperation('subscription-forget', () => serializeControl(async () => {
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
subscriptionCacheStore.remove();
|
subscriptionCacheStore.remove();
|
||||||
updateStoredState(() => ({}));
|
updateStoredState((state) => ({ routeRules: state.routeRules }));
|
||||||
gatewayAutoState = createGatewayAutoState();
|
gatewayAutoState = createGatewayAutoState();
|
||||||
}));
|
}));
|
||||||
return sendState(res);
|
return sendState(res);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 1;
|
export const STATE_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
const clone = (value) => structuredClone(value);
|
const clone = (value) => structuredClone(value);
|
||||||
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { HarborError } from '../shared/errors.js';
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../shared/routingRules.js';
|
||||||
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
|
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
|
||||||
|
|
||||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
@@ -17,7 +18,10 @@ function findOutbound(subscriptionConfig, selectedTag) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDirect = false } = {}) {
|
export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||||
|
clientDirect = false,
|
||||||
|
routeRules = [],
|
||||||
|
} = {}) {
|
||||||
const clientMode = settings.appMode === 'client';
|
const clientMode = settings.appMode === 'client';
|
||||||
const directClient = clientMode && clientDirect;
|
const directClient = clientMode && clientDirect;
|
||||||
const vpnOutbound = directClient
|
const vpnOutbound = directClient
|
||||||
@@ -48,7 +52,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire
|
|||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const directRules = [{ domain_suffix: ['ru'], outbound: 'direct' }];
|
const directRules = [...BUILT_IN_DIRECT_RULES, ...normalizeRouteRules(routeRules)].map((rule) => ({
|
||||||
|
[rule.type]: [rule.value],
|
||||||
|
outbound: 'direct',
|
||||||
|
}));
|
||||||
const rules = clientMode
|
const rules = clientMode
|
||||||
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
|
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../routingRules.js';
|
||||||
|
|
||||||
const MODES = new Set(['client', 'gateway']);
|
const MODES = new Set(['client', 'gateway']);
|
||||||
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
||||||
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
|
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
|
||||||
@@ -17,6 +19,7 @@ export function normalizeStoredState(value) {
|
|||||||
selectedTag,
|
selectedTag,
|
||||||
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
||||||
servers: Array.isArray(state.servers) ? state.servers : [],
|
servers: Array.isArray(state.servers) ? state.servers : [],
|
||||||
|
routeRules: normalizeRouteRules(state.routeRules),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +83,10 @@ export function createStateSnapshot({
|
|||||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||||
lastVerifiedAt: null,
|
lastVerifiedAt: null,
|
||||||
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
||||||
|
localRules: {
|
||||||
|
builtIn: BUILT_IN_DIRECT_RULES.map((rule) => ({ ...rule })),
|
||||||
|
custom: stored.routeRules,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
operation: {
|
operation: {
|
||||||
kind: nullableText(operation.kind),
|
kind: nullableText(operation.kind),
|
||||||
@@ -136,6 +143,12 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
server.port >= 0 &&
|
server.port >= 0 &&
|
||||||
typeof server.protocol === 'string'
|
typeof server.protocol === 'string'
|
||||||
);
|
);
|
||||||
|
const validRouteRule = (rule) => (
|
||||||
|
rule &&
|
||||||
|
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||||
|
typeof rule.value === 'string' &&
|
||||||
|
Boolean(rule.value)
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!snapshot ||
|
!snapshot ||
|
||||||
@@ -164,6 +177,11 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
!nullableString(snapshot.route.gatewayAddress) ||
|
!nullableString(snapshot.route.gatewayAddress) ||
|
||||||
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
||||||
typeof snapshot.route.reason !== 'string' ||
|
typeof snapshot.route.reason !== 'string' ||
|
||||||
|
!snapshot.route.localRules ||
|
||||||
|
!Array.isArray(snapshot.route.localRules.builtIn) ||
|
||||||
|
!snapshot.route.localRules.builtIn.every(validRouteRule) ||
|
||||||
|
!Array.isArray(snapshot.route.localRules.custom) ||
|
||||||
|
!snapshot.route.localRules.custom.every(validRouteRule) ||
|
||||||
!snapshot.operation ||
|
!snapshot.operation ||
|
||||||
!nullableString(snapshot.operation.kind) ||
|
!nullableString(snapshot.operation.kind) ||
|
||||||
!OPERATION_STATES.has(snapshot.operation.status) ||
|
!OPERATION_STATES.has(snapshot.operation.status) ||
|
||||||
|
|||||||
52
src/shared/routingRules.js
Normal file
52
src/shared/routingRules.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export const BUILT_IN_DIRECT_RULES = Object.freeze([
|
||||||
|
Object.freeze({ type: 'domain_suffix', value: 'ru' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
||||||
|
const MAX_RULES = 200;
|
||||||
|
|
||||||
|
function hostname(value) {
|
||||||
|
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
|
||||||
|
if (!input) throw new TypeError('Domain rule value is required');
|
||||||
|
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`);
|
||||||
|
const normalized = url.hostname.replace(/\.$/, '').toLowerCase();
|
||||||
|
if (!normalized || normalized.length > 253) throw new TypeError('Invalid domain rule value');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRule(rule) {
|
||||||
|
const type = String(rule?.type || '').trim();
|
||||||
|
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
|
||||||
|
const value = type === 'domain_keyword'
|
||||||
|
? String(rule?.value || '').trim().toLowerCase()
|
||||||
|
: hostname(rule?.value);
|
||||||
|
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
||||||
|
throw new TypeError('Invalid domain rule value');
|
||||||
|
}
|
||||||
|
return { type, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRouteRules(value, { strict = false } = {}) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
if (strict) throw new TypeError('Route rules must be an array');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (strict && value.length > MAX_RULES) throw new TypeError(`Route rules limit is ${MAX_RULES}`);
|
||||||
|
|
||||||
|
const seen = new Set(BUILT_IN_DIRECT_RULES.map(({ type, value: builtInValue }) => (
|
||||||
|
`${type}:${builtInValue}`
|
||||||
|
)));
|
||||||
|
const normalized = [];
|
||||||
|
for (const candidate of value.slice(0, MAX_RULES)) {
|
||||||
|
try {
|
||||||
|
const rule = normalizeRule(candidate);
|
||||||
|
const key = `${rule.type}:${rule.value}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
normalized.push(rule);
|
||||||
|
} catch (error) {
|
||||||
|
if (strict) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.2.0',
|
macClient: '0.3.0',
|
||||||
gatewayClient: '0.2.0',
|
gatewayClient: '0.3.0',
|
||||||
gatewayBackend: '0.2.0',
|
gatewayBackend: '0.3.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -88,10 +88,14 @@ function App() {
|
|||||||
: new HarborApiError({ code: err?.code }, err?.status);
|
: new HarborApiError({ code: err?.code }, err?.status);
|
||||||
setError({
|
setError({
|
||||||
context,
|
context,
|
||||||
message: safeError.message,
|
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||||
|
? 'Правила изменились. Закройте панель и откройте её снова.'
|
||||||
|
: safeError.message,
|
||||||
code: safeError.code,
|
code: safeError.code,
|
||||||
correlationId: safeError.correlationId,
|
correlationId: safeError.correlationId,
|
||||||
retry: safeError.retryable ? () => run(key, action, context) : null,
|
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
|
||||||
|
? () => run(key, action, context)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,11 @@ function App() {
|
|||||||
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
||||||
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
||||||
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
||||||
|
onSaveRouteRules={(rules, expectedRevision) => run(
|
||||||
|
'routeRules',
|
||||||
|
() => api.routeRules.update(rules, expectedRevision),
|
||||||
|
'routing',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
routeRules: {
|
||||||
|
update: (rules, expectedRevision) => request('/api/route-rules', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ rules, expectedRevision }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const operationProgress = {
|
|||||||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||||
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
|
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
|
||||||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||||
|
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function InlineProgress({ operations, context }) {
|
function InlineProgress({ operations, context }) {
|
||||||
@@ -195,6 +196,111 @@ function InstructionBlock({ block, open, onToggle }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUTE_RULE_OPTIONS = [
|
||||||
|
['domain', 'Точный домен'],
|
||||||
|
['domain_suffix', 'Суффикс'],
|
||||||
|
['domain_keyword', 'Содержит'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROUTE_RULE_PLACEHOLDERS = {
|
||||||
|
domain: 'example.com или полный URL',
|
||||||
|
domain_suffix: 'example.org',
|
||||||
|
domain_keyword: 'cdn',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LocalRulesPanel({
|
||||||
|
open,
|
||||||
|
rules,
|
||||||
|
blocked,
|
||||||
|
error,
|
||||||
|
operations,
|
||||||
|
panelRef,
|
||||||
|
onAdd,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
id="client-local-rules"
|
||||||
|
className={`client-local-rules${open ? ' is-open' : ''}`}
|
||||||
|
aria-labelledby="local-rules-title"
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-local-rules-sheet">
|
||||||
|
<header className="client-local-rules-header">
|
||||||
|
<span>Маршрутизация</span>
|
||||||
|
<h2 id="local-rules-title">Локальные правила</h2>
|
||||||
|
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="client-local-rules-form" onSubmit={onSave}>
|
||||||
|
<section className="client-local-rules-group" aria-labelledby="built-in-rules-title">
|
||||||
|
<span id="built-in-rules-title">Базовое правило</span>
|
||||||
|
<div className="client-local-rule is-built-in">
|
||||||
|
<strong>Суффикс</strong>
|
||||||
|
<code>*.ru</code>
|
||||||
|
<small>Всегда напрямую</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="client-local-rules-group" aria-labelledby="custom-rules-title">
|
||||||
|
<span id="custom-rules-title">Ваши правила</span>
|
||||||
|
<div className="client-local-rules-list">
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<div className="client-local-rule" key={index}>
|
||||||
|
<select
|
||||||
|
aria-label={`Тип правила ${index + 1}`}
|
||||||
|
value={rule.type}
|
||||||
|
onChange={(event) => onChange(index, 'type', event.target.value)}
|
||||||
|
>
|
||||||
|
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
|
||||||
|
<option value={value} key={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
aria-label={`Значение правила ${index + 1}`}
|
||||||
|
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
|
||||||
|
value={rule.value}
|
||||||
|
onChange={(event) => onChange(index, 'value', event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить правило ${index + 1}`}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
|
||||||
|
</div>
|
||||||
|
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="client-local-rules-note">
|
||||||
|
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||||||
|
</p>
|
||||||
|
<InlineError error={error} context="routing" />
|
||||||
|
<InlineProgress operations={operations} context="routing" />
|
||||||
|
<div className="client-local-rules-actions">
|
||||||
|
<button type="button" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="is-primary" type="submit" disabled={blocked}>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DurationPart({ name, children }) {
|
function DurationPart({ name, children }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -305,6 +411,7 @@ export function ClientOverviewPage({
|
|||||||
onRestart,
|
onRestart,
|
||||||
onStop,
|
onStop,
|
||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
|
onSaveRouteRules,
|
||||||
}) {
|
}) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||||||
@@ -335,6 +442,9 @@ export function ClientOverviewPage({
|
|||||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
const [serversLeaving, setServersLeaving] = useState(false);
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
|
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||||
|
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||||
|
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||||
const subscriptionInputRef = useRef(null);
|
const subscriptionInputRef = useRef(null);
|
||||||
@@ -342,6 +452,8 @@ export function ClientOverviewPage({
|
|||||||
const copyTimerRef = useRef(null);
|
const copyTimerRef = useRef(null);
|
||||||
const instructionsPanelRef = useRef(null);
|
const instructionsPanelRef = useRef(null);
|
||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
|
const localRulesPanelRef = useRef(null);
|
||||||
|
const localRulesToggleRef = useRef(null);
|
||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
const validationRequests = useRef(null);
|
const validationRequests = useRef(null);
|
||||||
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
||||||
@@ -381,6 +493,7 @@ export function ClientOverviewPage({
|
|||||||
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
|
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
|
||||||
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||||
|
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -498,6 +611,7 @@ export function ClientOverviewPage({
|
|||||||
if (!hasSubscription) {
|
if (!hasSubscription) {
|
||||||
setEditingSubscription(true);
|
setEditingSubscription(true);
|
||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
|
setLocalRulesOpen(false);
|
||||||
}
|
}
|
||||||
}, [hasSubscription]);
|
}, [hasSubscription]);
|
||||||
|
|
||||||
@@ -551,6 +665,22 @@ export function ClientOverviewPage({
|
|||||||
return () => document.removeEventListener('keydown', closeOnEscape);
|
return () => document.removeEventListener('keydown', closeOnEscape);
|
||||||
}, [instructionsOpen]);
|
}, [instructionsOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localRulesOpen) return undefined;
|
||||||
|
const closeLocalRules = (event) => {
|
||||||
|
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||||||
|
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||||||
|
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||||||
|
setLocalRulesOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeLocalRules);
|
||||||
|
document.addEventListener('keydown', closeLocalRules);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', closeLocalRules);
|
||||||
|
document.removeEventListener('keydown', closeLocalRules);
|
||||||
|
};
|
||||||
|
}, [localRulesOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instructionsOpen) return undefined;
|
if (!instructionsOpen) return undefined;
|
||||||
const closeInstructionsOutside = (event) => {
|
const closeInstructionsOutside = (event) => {
|
||||||
@@ -644,6 +774,25 @@ export function ClientOverviewPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLocalRules() {
|
||||||
|
setInstructionsOpen(false);
|
||||||
|
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
|
||||||
|
setLocalRulesRevision(state?.revision || 0);
|
||||||
|
setLocalRulesOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLocalRule(index, field, value) {
|
||||||
|
setLocalRulesDraft((rules) => rules.map((rule, ruleIndex) => (
|
||||||
|
ruleIndex === index ? { ...rule, [field]: value } : rule
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLocalRules(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
|
||||||
|
setLocalRulesOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
@@ -661,7 +810,10 @@ export function ClientOverviewPage({
|
|||||||
aria-expanded={instructionsOpen}
|
aria-expanded={instructionsOpen}
|
||||||
aria-controls="client-instructions"
|
aria-controls="client-instructions"
|
||||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||||
onClick={() => setInstructionsOpen((open) => !open)}
|
onClick={() => {
|
||||||
|
setLocalRulesOpen(false);
|
||||||
|
setInstructionsOpen((open) => !open);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="m3 5 7 7-7 7" />
|
<path d="m3 5 7 7-7 7" />
|
||||||
@@ -670,6 +822,22 @@ export function ClientOverviewPage({
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Как использовать</span>
|
<span>Как использовать</span>
|
||||||
</button>}
|
</button>}
|
||||||
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
|
ref={localRulesToggleRef}
|
||||||
|
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={localRulesOpen}
|
||||||
|
aria-controls="client-local-rules"
|
||||||
|
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||||
|
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||||
|
<circle cx="15" cy="7" r="2" />
|
||||||
|
<circle cx="9" cy="17" r="2" />
|
||||||
|
</svg>
|
||||||
|
<span>Локальные правила</span>
|
||||||
|
</button>}
|
||||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||||
{showPower && (
|
{showPower && (
|
||||||
<section className="client-power-section" aria-labelledby="connection-title">
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
@@ -1040,6 +1208,20 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</aside>}
|
</aside>}
|
||||||
|
|
||||||
|
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
|
||||||
|
open={localRulesOpen}
|
||||||
|
rules={localRulesDraft}
|
||||||
|
blocked={routeRulesBlocked}
|
||||||
|
error={error}
|
||||||
|
operations={operations}
|
||||||
|
panelRef={localRulesPanelRef}
|
||||||
|
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
|
||||||
|
onChange={changeLocalRule}
|
||||||
|
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
|
||||||
|
onClose={() => setLocalRulesOpen(false)}
|
||||||
|
onSave={saveLocalRules}
|
||||||
|
/>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export const OPERATION_CONFLICTS = Object.freeze({
|
export const OPERATION_CONFLICTS = Object.freeze({
|
||||||
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||||
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||||
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||||
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto'],
|
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
|
||||||
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto'],
|
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
|
||||||
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete'],
|
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
|
||||||
|
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export function operationBlocked(operations, key) {
|
export function operationBlocked(operations, key) {
|
||||||
|
|||||||
@@ -717,6 +717,317 @@ p {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 30;
|
||||||
|
width: 42px;
|
||||||
|
height: 54px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: color 240ms ease, transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle svg {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transition: color 240ms ease, filter 360ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle span {
|
||||||
|
position: absolute;
|
||||||
|
right: 48px;
|
||||||
|
width: max-content;
|
||||||
|
color: var(--client-text);
|
||||||
|
font: 700 10px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(5px);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(8px);
|
||||||
|
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle:hover,
|
||||||
|
.client-local-rules-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--client-accent);
|
||||||
|
transform: translateY(-50%) translateX(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle:hover svg,
|
||||||
|
.client-local-rules-toggle:focus-visible svg {
|
||||||
|
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 52%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle:hover span,
|
||||||
|
.client-local-rules-toggle:focus-visible span {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle.is-open {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle.is-open svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle.is-open span {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(480px, 100vw);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateX(104%);
|
||||||
|
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-sheet {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 54px 72px 72px 34px;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0 8px 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header > span,
|
||||||
|
.client-local-rules-group > span {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header p {
|
||||||
|
max-width: 46ch;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-form,
|
||||||
|
.client-local-rules-group,
|
||||||
|
.client-local-rules-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-form {
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-group {
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-list {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr) 32px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
|
||||||
|
box-shadow: 0 10px 28px oklch(0.1 0.015 145 / 0.055);
|
||||||
|
transition: background 240ms ease, box-shadow 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule:focus-within {
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 74%, var(--client-bg));
|
||||||
|
box-shadow: 0 14px 34px oklch(0.1 0.015 145 / 0.09);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule.is-built-in {
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr) auto;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule.is-built-in strong {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule.is-built-in code {
|
||||||
|
color: var(--client-accent);
|
||||||
|
font: 700 13px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule.is-built-in small {
|
||||||
|
font-size: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule select,
|
||||||
|
.client-local-rule input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 0;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 72%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule select {
|
||||||
|
padding: 0 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule input {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule input::placeholder {
|
||||||
|
color: color-mix(in oklch, var(--client-muted) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule select:focus-visible,
|
||||||
|
.client-local-rule input:focus-visible,
|
||||||
|
.client-local-rule button:focus-visible,
|
||||||
|
.client-local-rule-add:focus-visible,
|
||||||
|
.client-local-rules-actions button:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule > button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease, background 180ms ease, transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule > button:hover {
|
||||||
|
background: color-mix(in oklch, oklch(0.68 0.15 28) 10%, transparent);
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-empty {
|
||||||
|
padding: 14px 4px 4px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule-add {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 7px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule-add:hover {
|
||||||
|
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-note {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 44%, transparent);
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-inline-error.is-routing {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-actions button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 72%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease, color 200ms ease, transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-actions button:hover:not(:disabled) {
|
||||||
|
background: var(--client-control);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-actions .is-primary {
|
||||||
|
background: color-mix(in oklch, var(--client-accent) 18%, var(--client-control));
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-actions button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions {
|
.client-instructions {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -2197,10 +2508,43 @@ p {
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle {
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-sheet {
|
.client-instructions-sheet {
|
||||||
padding: 40px 18px 60px 58px;
|
padding: 40px 18px 60px 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-sheet {
|
||||||
|
padding: 40px 58px 60px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule,
|
||||||
|
.client-local-rule.is-built-in {
|
||||||
|
grid-template-columns: 1fr 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule select,
|
||||||
|
.client-local-rule.is-built-in strong {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule input,
|
||||||
|
.client-local-rule.is-built-in code {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule.is-built-in small {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-header h2 {
|
.client-instructions-header h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
@@ -2260,6 +2604,17 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules,
|
||||||
|
.client-local-rules-toggle,
|
||||||
|
.client-local-rules-toggle svg,
|
||||||
|
.client-local-rules-toggle span,
|
||||||
|
.client-local-rule,
|
||||||
|
.client-local-rule > button,
|
||||||
|
.client-local-rules-actions button {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
.harbor-brand {
|
.harbor-brand {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -21,13 +21,20 @@ const subscriptionConfig = {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
test('client exposes one local proxy and routes it through the selected VPN', () => {
|
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
|
||||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||||
|
routeRules: [
|
||||||
|
{ type: 'domain', value: 'example.com' },
|
||||||
|
{ type: 'domain_keyword', value: 'cdn' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
|
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
|
||||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||||
assert.deepEqual(config.route.rules, [
|
assert.deepEqual(config.route.rules, [
|
||||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||||
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
|
{ domain_keyword: ['cdn'], outbound: 'direct' },
|
||||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
]);
|
]);
|
||||||
assert.equal(config.route.final, 'test-vpn');
|
assert.equal(config.route.final, 'test-vpn');
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ setInterval(() => {}, 60_000);
|
|||||||
});
|
});
|
||||||
assertStateSnapshot(initial);
|
assertStateSnapshot(initial);
|
||||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||||
|
assert.deepEqual(initial.route.localRules, {
|
||||||
|
builtIn: [{ type: 'domain_suffix', value: 'ru' }],
|
||||||
|
custom: [],
|
||||||
|
});
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const stateKeys = Object.keys(initial).sort();
|
const stateKeys = Object.keys(initial).sort();
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
@@ -234,6 +238,66 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||||
|
|
||||||
|
const rulesRevision = revision;
|
||||||
|
const routed = await mutation('/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: rulesRevision,
|
||||||
|
rules: [
|
||||||
|
{ type: 'domain', value: 'https://Example.com/private?q=1' },
|
||||||
|
{ type: 'domain_suffix', value: '*.Example.org' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(routed.state.route.localRules.custom, [
|
||||||
|
{ type: 'domain', value: 'example.com' },
|
||||||
|
{ type: 'domain_suffix', value: 'example.org' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
||||||
|
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||||
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: revision,
|
||||||
|
rules: [{ type: 'domain_regex', value: '.*' }],
|
||||||
|
});
|
||||||
|
assert.equal(invalidRules.response.status, 400);
|
||||||
|
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
|
||||||
|
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||||
|
|
||||||
|
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: rulesRevision,
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
assert.equal(staleRules.response.status, 409);
|
||||||
|
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
||||||
|
assert.deepEqual((await request(port, '/api/state')).route.localRules.custom, routed.state.route.localRules.custom);
|
||||||
|
|
||||||
|
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||||
|
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs');
|
||||||
|
if (process.argv[2] === 'check') {
|
||||||
|
const config = fs.readFileSync(process.argv[4], 'utf8');
|
||||||
|
process.exit(config.includes('broken.example') ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (process.argv[2] === 'version') process.exit(0);
|
||||||
|
process.on('SIGTERM', () => process.exit(0));
|
||||||
|
setInterval(() => {}, 60_000);
|
||||||
|
`);
|
||||||
|
fs.chmodSync(singboxPath, 0o755);
|
||||||
|
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: revision,
|
||||||
|
rules: [{ type: 'domain', value: 'broken.example' }],
|
||||||
|
});
|
||||||
|
assert.equal(failedRules.response.status, 422);
|
||||||
|
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
|
||||||
|
const rolledBack = await request(port, '/api/state');
|
||||||
|
assert.deepEqual(rolledBack.route.localRules.custom, routed.state.route.localRules.custom);
|
||||||
|
assert.equal(rolledBack.connection.process, 'running');
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
|
||||||
|
revision = rolledBack.revision;
|
||||||
|
fs.writeFileSync(singboxPath, workingSingbox);
|
||||||
|
fs.chmodSync(singboxPath, 0o755);
|
||||||
|
|
||||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||||
if (process.argv[2] === 'check') {
|
if (process.argv[2] === 'check') {
|
||||||
require('node:fs').unlinkSync(process.argv[1]);
|
require('node:fs').unlinkSync(process.argv[1]);
|
||||||
@@ -253,6 +317,7 @@ if (process.argv[2] === 'check') {
|
|||||||
const forgotten = await mutation('/api/subscription', 'DELETE');
|
const forgotten = await mutation('/api/subscription', 'DELETE');
|
||||||
assert.equal(forgotten.state.subscription.status, 'missing');
|
assert.equal(forgotten.state.subscription.status, 'missing');
|
||||||
assert.equal(forgotten.state.servers.length, 0);
|
assert.equal(forgotten.state.servers.length, 0);
|
||||||
|
assert.deepEqual(forgotten.state.route.localRules.custom, routed.state.route.localRules.custom);
|
||||||
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
|
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
|
||||||
|
|
||||||
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ test('a failure before rename preserves the last successful file', (t) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('legacy state migrates to schema v1 and keeps a backup', (t) => {
|
test('schema v1 state migrates to the current schema and keeps a backup', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const legacy = {
|
const legacy = {
|
||||||
|
schemaVersion: 1,
|
||||||
revision: 7,
|
revision: 7,
|
||||||
selectedTag: 'nl',
|
selectedTag: 'nl',
|
||||||
servers: [{ tag: 'nl' }],
|
servers: [{ tag: 'nl' }],
|
||||||
@@ -49,9 +50,9 @@ test('legacy state migrates to schema v1 and keeps a backup', (t) => {
|
|||||||
|
|
||||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migrated.appliedTag, 'nl');
|
assert.equal(migrated.appliedTag, 'nl');
|
||||||
assert.equal(store.migration.fromVersion, 0);
|
assert.equal(store.migration.fromVersion, 1);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
||||||
@@ -63,11 +64,11 @@ test('corrupt JSON is preserved and replaced with an explicit recovery state', (
|
|||||||
});
|
});
|
||||||
const recovered = store.read();
|
const recovered = store.read();
|
||||||
|
|
||||||
assert.equal(recovered.schemaVersion, 1);
|
assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(recovered.revision, 0);
|
assert.equal(recovered.revision, 0);
|
||||||
assert.equal(store.recovery.kind, 'corrupt-json');
|
assert.equal(store.recovery.kind, 'corrupt-json');
|
||||||
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
|
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('concurrent updates are serialized without lost values', async (t) => {
|
test('concurrent updates are serialized without lost values', async (t) => {
|
||||||
|
|||||||
29
test/shared/routing-rules.test.js
Normal file
29
test/shared/routing-rules.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { normalizeRouteRules } from '../../src/shared/routingRules.js';
|
||||||
|
|
||||||
|
test('local route rules normalize URLs, suffixes and duplicates', () => {
|
||||||
|
assert.deepEqual(normalizeRouteRules([
|
||||||
|
{ type: 'domain', value: 'https://Example.com/news?id=1' },
|
||||||
|
{ type: 'domain_suffix', value: '*.Example.org' },
|
||||||
|
{ type: 'domain_keyword', value: ' CDN ' },
|
||||||
|
{ type: 'domain', value: 'example.com' },
|
||||||
|
{ type: 'domain_suffix', value: '.ru' },
|
||||||
|
], { strict: true }), [
|
||||||
|
{ type: 'domain', value: 'example.com' },
|
||||||
|
{ type: 'domain_suffix', value: 'example.org' },
|
||||||
|
{ type: 'domain_keyword', value: 'cdn' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid local route rules fail at the strict boundary', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRouteRules([{ type: 'domain_regex', value: '.*' }], { strict: true }),
|
||||||
|
/Invalid domain rule type/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeRouteRules([{ type: 'domain_keyword', value: 'bad/path' }], { strict: true }),
|
||||||
|
/Invalid domain rule value/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user