From a0c66edb0210fccb0defca926ff87b37bd2a59b5 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 21:52:03 +0300 Subject: [PATCH] Add local routing rules to Harbor --- README.md | 6 + docs/recovery/state-recovery.md | 4 +- src/server/index.js | 63 +++- src/server/services/stateStore.js | 2 +- src/server/singbox.js | 11 +- src/shared/contracts/state.js | 18 ++ src/shared/routingRules.js | 52 ++++ src/shared/versions.js | 6 +- src/web/App.jsx | 13 +- src/web/api.js | 6 + src/web/components/ClientOverviewPage.jsx | 184 ++++++++++- src/web/state/operations.js | 13 +- src/web/styles.css | 355 ++++++++++++++++++++++ test/server/singbox-client-mode.test.js | 11 +- test/server/state-contract.test.js | 65 ++++ test/server/state-store.test.js | 11 +- test/shared/routing-rules.test.js | 29 ++ 17 files changed, 823 insertions(+), 26 deletions(-) create mode 100644 src/shared/routingRules.js create mode 100644 test/shared/routing-rules.test.js diff --git a/README.md b/README.md index 7e02c0b..1191292 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,12 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \ Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси. +## Локальные правила маршрутизации + +После добавления подписки откройте «Локальные правила» справа от основного экрана. Встроенное правило `*.ru` всегда отправляет российские домены напрямую. Дополнительно можно добавить точный домен, suffix домена или фрагмент имени; эти правила также обходят VPN, а остальной трафик идёт через выбранный сервер. + +Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются. + ## Системный прокси macOS Сначала посмотрите точное имя сетевого подключения: diff --git a/docs/recovery/state-recovery.md b/docs/recovery/state-recovery.md index 6d2bdf7..38ae312 100644 --- a/docs/recovery/state-recovery.md +++ b/docs/recovery/state-recovery.md @@ -1,6 +1,6 @@ # 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 @@ -8,7 +8,7 @@ Persistent files are written to a unique temporary file in the same directory, f ## 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 state.json.backup-v0-2026-07-11T12-00-00-000Z diff --git a/src/server/index.js b/src/server/index.js index 6b46860..9370111 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -30,6 +30,7 @@ import { withStateV0Compatibility, } from '../shared/contracts/state.js'; import { HarborError, normalizeHarborError } from '../shared/errors.js'; +import { normalizeRouteRules } from '../shared/routingRules.js'; import { createJsonStore, createStateStore } from './services/stateStore.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, { 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() { if (subscriptionRefreshPromise) return subscriptionRefreshPromise; @@ -515,6 +553,7 @@ async function handleApi(req, res) { removeSingboxConfig(); subscriptionCacheStore.write({ url: normalizedUrl, ...result }); updateStoredState((state) => ({ + routeRules: state.routeRules, subscriptionUrl: normalizedUrl, gatewayAutoEnabled: state.gatewayAutoEnabled !== false, servers: result.servers, @@ -564,12 +603,32 @@ async function handleApi(req, res) { 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') { await withOperation('subscription-forget', () => serializeControl(async () => { await stopSingbox(); removeSingboxConfig(); subscriptionCacheStore.remove(); - updateStoredState(() => ({})); + updateStoredState((state) => ({ routeRules: state.routeRules })); gatewayAutoState = createGatewayAutoState(); })); return sendState(res); diff --git a/src/server/services/stateStore.js b/src/server/services/stateStore.js index e29b635..1127649 100644 --- a/src/server/services/stateStore.js +++ b/src/server/services/stateStore.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; 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 stamp = (value) => value.toISOString().replace(/[:.]/g, '-'); diff --git a/src/server/singbox.js b/src/server/singbox.js index c3eb8ae..f89e9e9 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { settings } from './config.js'; import { HarborError } from '../shared/errors.js'; +import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../shared/routingRules.js'; import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js'; 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 directClient = clientMode && clientDirect; const vpnOutbound = directClient @@ -48,7 +52,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire 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 ? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }] : [ diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js index 0f9cebf..f00e816 100644 --- a/src/shared/contracts/state.js +++ b/src/shared/contracts/state.js @@ -1,3 +1,5 @@ +import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../routingRules.js'; + const MODES = new Set(['client', 'gateway']); const CONNECTION_STATES = new Set(['running', 'stopped']); const OPERATION_STATES = new Set(['idle', 'running', 'failed']); @@ -17,6 +19,7 @@ export function normalizeStoredState(value) { selectedTag, appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag, 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, lastVerifiedAt: null, reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', + localRules: { + builtIn: BUILT_IN_DIRECT_RULES.map((rule) => ({ ...rule })), + custom: stored.routeRules, + }, }, operation: { kind: nullableText(operation.kind), @@ -136,6 +143,12 @@ export function assertStateSnapshot(snapshot) { server.port >= 0 && typeof server.protocol === 'string' ); + const validRouteRule = (rule) => ( + rule && + ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) && + typeof rule.value === 'string' && + Boolean(rule.value) + ); if ( !snapshot || @@ -164,6 +177,11 @@ export function assertStateSnapshot(snapshot) { !nullableString(snapshot.route.gatewayAddress) || !nullableDate(snapshot.route.lastVerifiedAt) || 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 || !nullableString(snapshot.operation.kind) || !OPERATION_STATES.has(snapshot.operation.status) || diff --git a/src/shared/routingRules.js b/src/shared/routingRules.js new file mode 100644 index 0000000..f600ca2 --- /dev/null +++ b/src/shared/routingRules.js @@ -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; +} diff --git a/src/shared/versions.js b/src/shared/versions.js index 195baa5..1fd2912 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.2.0', - gatewayClient: '0.2.0', - gatewayBackend: '0.2.0', + macClient: '0.3.0', + gatewayClient: '0.3.0', + gatewayBackend: '0.3.0', }); export function parseVersion(value) { diff --git a/src/web/App.jsx b/src/web/App.jsx index 625edfc..86bea72 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -88,10 +88,14 @@ function App() { : new HarborApiError({ code: err?.code }, err?.status); setError({ context, - message: safeError.message, + message: context === 'routing' && safeError.code === 'STATE_CONFLICT' + ? 'Правила изменились. Закройте панель и откройте её снова.' + : safeError.message, code: safeError.code, 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; } @@ -156,6 +160,11 @@ function App() { onRestart={() => run('connection', api.singbox.restart, 'connection')} onStop={() => run('connection', api.singbox.stop, 'connection')} onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} + onSaveRouteRules={(rules, expectedRevision) => run( + 'routeRules', + () => api.routeRules.update(rules, expectedRevision), + 'routing', + )} /> diff --git a/src/web/api.js b/src/web/api.js index 21e2eae..530f257 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -76,6 +76,12 @@ export const api = { body: JSON.stringify({ enabled }), }), }, + routeRules: { + update: (rules, expectedRevision) => request('/api/route-rules', { + method: 'PUT', + body: JSON.stringify({ rules, expectedRevision }), + }), + }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 94b28c7..2b6b14b 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -133,6 +133,7 @@ const operationProgress = { subscriptionImport: ['subscription', 'Загружаем подписку…'], subscriptionRefresh: ['subscription', 'Обновляем подписку…'], subscriptionDelete: ['subscription', 'Удаляем подписку…'], + routeRules: ['routing', 'Применяем локальные правила…'], }; 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 ( + + ); +} + function DurationPart({ name, children }) { return ( { setNow(Date.now()); @@ -498,6 +611,7 @@ export function ClientOverviewPage({ if (!hasSubscription) { setEditingSubscription(true); setInstructionsOpen(false); + setLocalRulesOpen(false); } }, [hasSubscription]); @@ -551,6 +665,22 @@ export function ClientOverviewPage({ return () => document.removeEventListener('keydown', closeOnEscape); }, [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(() => { if (!instructionsOpen) return undefined; 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 (
@@ -661,7 +810,10 @@ export function ClientOverviewPage({ aria-expanded={instructionsOpen} aria-controls="client-instructions" aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'} - onClick={() => setInstructionsOpen((open) => !open)} + onClick={() => { + setLocalRulesOpen(false); + setInstructionsOpen((open) => !open); + }} > Как использовать } + {hasSubscription && subscriptionContentReady && }
{showPower && (
@@ -1040,6 +1208,20 @@ export function ClientOverviewPage({
} + {hasSubscription && subscriptionContentReady && setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])} + onChange={changeLocalRule} + onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))} + onClose={() => setLocalRulesOpen(false)} + onSave={saveLocalRules} + />} + ); } diff --git a/src/web/state/operations.js b/src/web/state/operations.js index 3f61819..07470ac 100644 --- a/src/web/state/operations.js +++ b/src/web/state/operations.js @@ -1,10 +1,11 @@ export const OPERATION_CONFLICTS = Object.freeze({ - connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], - serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], - subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], - subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto'], - subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto'], - gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete'], + connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'], + serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'], + subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'], + subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'], + subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'], + gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'], + routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], }); export function operationBlocked(operations, key) { diff --git a/src/web/styles.css b/src/web/styles.css index 8cb3a19..4128a32 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -717,6 +717,317 @@ p { 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 { position: fixed; inset: 0 auto 0 0; @@ -2197,10 +2508,43 @@ p { left: 8px; } + .client-local-rules-toggle { + right: 8px; + } + .client-instructions-sheet { 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 { font-size: 18px; } @@ -2260,6 +2604,17 @@ p { 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 { transition: none; animation: none; diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 6b8fa14..e38f07e 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -21,13 +21,20 @@ const subscriptionConfig = { }], }; -test('client exposes one local proxy and routes it through the selected VPN', () => { - const config = buildGatewayConfig(subscriptionConfig, 'test-vpn'); +test('client exposes one local proxy and routes local exceptions before the selected 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.equal(config.inbounds[0].listen_port, 8082); assert.deepEqual(config.route.rules, [ { domain_suffix: ['ru'], outbound: 'direct' }, + { domain: ['example.com'], outbound: 'direct' }, + { domain_keyword: ['cdn'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); assert.equal(config.route.final, 'test-vpn'); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 4a9eb66..2d798fb 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -169,6 +169,10 @@ setInterval(() => {}, 60_000); }); assertStateSnapshot(initial); 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); const stateKeys = Object.keys(initial).sort(); 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/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 if (process.argv[2] === 'check') { require('node:fs').unlinkSync(process.argv[1]); @@ -253,6 +317,7 @@ if (process.argv[2] === 'check') { const forgotten = await mutation('/api/subscription', 'DELETE'); assert.equal(forgotten.state.subscription.status, 'missing'); 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, []); const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST'); diff --git a/test/server/state-store.test.js b/test/server/state-store.test.js index b4be037..7f800b2 100644 --- a/test/server/state-store.test.js +++ b/test/server/state-store.test.js @@ -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 legacy = { + schemaVersion: 1, revision: 7, selectedTag: '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.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.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) => { @@ -63,11 +64,11 @@ test('corrupt JSON is preserved and replaced with an explicit recovery state', ( }); const recovered = store.read(); - assert.equal(recovered.schemaVersion, 1); + assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION); assert.equal(recovered.revision, 0); assert.equal(store.recovery.kind, 'corrupt-json'); 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) => { diff --git a/test/shared/routing-rules.test.js b/test/shared/routing-rules.test.js new file mode 100644 index 0000000..c61958a --- /dev/null +++ b/test/shared/routing-rules.test.js @@ -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/, + ); +});