diff --git a/README.md b/README.md index a73e8a1..fccae36 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,15 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси. -## Локальные правила маршрутизации +## Правила маршрутизации -После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер. +После добавления подписки откройте «Правила маршрутизации» справа от основного экрана. При первом обновлении Harbor добавит включённое правило `*.ru → Напрямую`. Для каждого точного домена, suffix или фрагмента имени можно выбрать результат `VPN` либо `Напрямую`, выключить правило или удалить его. Правила проверяются сверху вниз, первое совпадение выбирает маршрут. Чтобы изменить порядок, возьмите строку за три точки слева и перетащите; с клавиатуры нажмите на этом хвате `Space` или `Enter`, переместите правило стрелками и повторно нажмите для размещения. + +Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения. Для устройства Gateway с маршрутом `VPN` и при обычном локальном VPN список применяется. Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются. -При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе. +При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются. ## Системный прокси macOS diff --git a/docs/product/application-state.md b/docs/product/application-state.md index f066fdd..8665a2b 100644 --- a/docs/product/application-state.md +++ b/docs/product/application-state.md @@ -1,6 +1,6 @@ # Harbor application state v1 -`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v5; React keeps only drafts, disclosure, focus, animation and transport freshness. +`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v6; React keeps only drafts, disclosure, focus, animation and transport freshness. An abbreviated snapshot: @@ -89,10 +89,20 @@ The canonical API is scoped by profile: Provider failure retains the last successful list and metadata, marks only the target profile stale and records the last successful timestamp. Refreshing, pinging or deleting an inactive profile does not mutate the applied config/runtime. Background refresh iterates profiles independently every 15 minutes. +## Ordered routing rules + +`route.localRules` is the desired ordered list. Every rule has an explicit `outbound: "vpn" | "direct"`; the first enabled matcher wins and disabled rules retain their position without entering the generated config. `route.activeLocalRules` is the exact canonical list used to generate the running rules-enabled config, not a second desired owner. + +The route-rules mutation uses the whole-array `PUT /api/route-rules/v2` with `rulesContractVersion: 2` and `expectedRulesRevision`. Contract v2 requires an explicit outbound on every rule. The versioned path prevents a stale v2 tab from writing to a rolled-back v1 backend; the legacy path on a v2 backend rejects its payload without changing state, config or runtime. A client that receives a snapshot without capability version 2 can read legacy rules as direct but keeps the editor read-only. + +In Connect `gateway-direct`, local user rules are intentionally omitted and the snapshot reports no active or pending local rules. Gateway device policy `Напрямую` bypasses sing-box before these rules; policy `VPN` and an ordinary local/Gateway VPN pipeline evaluate them. + ## Compatibility and migration Schema v5 migrates the legacy singleton and `subscription-cache.json` into one profile named `Основной`. Stable endpoint identity preserves unambiguous desired/applied selection, including transport variants whose normalized IDs differ from old labels. An explicitly stopped legacy state does not resurrect an old applied target. +Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. + Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config. -The old HTTP projection remains bounded for one release, but schema v5 persistence is not downgrade-compatible. To roll back to a pre-v5 build, stop Harbor and restore both pre-migration backups rather than deploying old code over v5 data. +The old HTTP projection remains bounded for one release. Schema v6 persistence is not downgrade-compatible: stop Harbor and restore the `state.json.backup-v-*` matching the rollback binary instead of deploying old code over v6 data. Rolling back before profiles still also requires the matching legacy subscription-cache backup. diff --git a/docs/recovery/state-recovery.md b/docs/recovery/state-recovery.md index ac40bca..a5c75ff 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 directory and `state.json` path. The current persisted format is `schemaVersion: 5`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, and v5 embeds the canonical `profiles[]` collection with desired/applied profile identity. +Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 6`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, v5 embeds the canonical `profiles[]` collection with desired/applied profile identity, and v6 adds an explicit `vpn` or `direct` outbound to every route rule. ## Atomic writes @@ -26,6 +26,16 @@ subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state. +## Migration to ordered VPN/Direct rules + +When schemas v0-v5 are read, Harbor preserves the order of `routeRules` and `appliedRouteRules` and adds `outbound: "direct"` to legacy entries before atomically committing schema v6. The original file is preserved using its actual source version, for example: + +```text +state.json.backup-v5-2026-08-17T12-00-00-000Z +``` + +After migration, malformed schema-v6 rules are rejected; Harbor does not reinterpret a missing or unknown outbound as direct. + ## Corrupt JSON If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: @@ -45,4 +55,4 @@ Perform recovery while Harbor is stopped: 3. Restore only matching state/cache backups to their original filenames. 4. Start Harbor and verify `GET /api/state` before applying a profile. -A pre-v5 binary cannot interpret canonical profiles. Downgrade therefore requires both pre-migration backups; deploying old code alone is not a safe rollback. +A pre-v6 binary cannot interpret the explicit ordered VPN/Direct rule contract. Restore `state.json.backup-v-*` matching the rollback binary; deploying old code over schema v6 is not safe. A rollback to pre-v5 additionally requires the matching state and subscription-cache backups because that binary cannot interpret canonical profiles. diff --git a/src/server/features/connection/connectionService.ts b/src/server/features/connection/connectionService.ts index e64d545..afed66b 100644 --- a/src/server/features/connection/connectionService.ts +++ b/src/server/features/connection/connectionService.ts @@ -237,7 +237,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc appliedServerId: server.id, appliedServerSnapshot: server, connectionDesired: 'running', - appliedRouteRules: state.routeRules, + appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules, })); } catch (error) { await finishRollback(error, [ diff --git a/src/server/features/routing/gatewayAutoService.ts b/src/server/features/routing/gatewayAutoService.ts index d564a20..6a07a52 100644 --- a/src/server/features/routing/gatewayAutoService.ts +++ b/src/server/features/routing/gatewayAutoService.ts @@ -205,7 +205,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende current = candidate; gatewayAutoPublished = true; stateCommitStarted = true; - dependencies.state.update((state) => state); + dependencies.state.update((state) => ({ + ...state, + ...(modeChanged && wasRunning && reconfigure ? { + appliedRouteRules: candidate.mode === 'gateway-direct' ? [] : state.routeRules, + } : {}), + })); } if (persistEnabled !== undefined) { stateCommitStarted = true; diff --git a/src/server/features/routing/routeRulesService.ts b/src/server/features/routing/routeRulesService.ts index a35f17e..e771914 100644 --- a/src/server/features/routing/routeRulesService.ts +++ b/src/server/features/routing/routeRulesService.ts @@ -7,7 +7,10 @@ import { type StoredState, } from '../../../shared/contracts/state.js'; import { HarborError } from '../../../shared/errors.js'; -import { normalizeRouteRules } from '../../../shared/routingRules.js'; +import { + normalizeRouteRules, + ROUTE_RULES_CONTRACT_VERSION, +} from '../../../shared/routingRules.js'; import type { RuntimeCommandResult } from '../connection/index.js'; import { finishRollback } from '../../services/rollback.js'; @@ -29,6 +32,7 @@ interface RouteRulesDependencies { applyCommand(): Promise; restoreRunning(): Promise; }; + route?: { isGatewayDirect(): boolean }; serialize(operation: () => Promise): Promise; runOperation(operation: () => Promise): Promise; } @@ -36,6 +40,7 @@ interface RouteRulesDependencies { export function createRouteRulesService(dependencies: RouteRulesDependencies) { const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => { const wasRunning = await dependencies.runtime.isRunning(); + const bypassed = dependencies.route?.isGatewayDirect() === true; const targetProfile = wasRunning ? appliedProfile(previousState) : desiredProfile(previousState); @@ -45,13 +50,14 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) { const subscriptionConfig = targetProfile ? dependencies.subscription.readConfig(targetProfile.id) : null; - if (!targetServerId || !subscriptionConfig) { + if (bypassed || !targetServerId || !subscriptionConfig) { let stateCommitStarted = false; try { stateCommitStarted = true; dependencies.state.update((state) => ({ ...state, routeRules, + ...(bypassed ? { appliedRouteRules: [] } : {}), routeRulesRevision: state.routeRulesRevision + 1, })); } catch (error) { @@ -68,14 +74,17 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) { routeRules, ); const previousConfig = dependencies.config.read(); + const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2); let configMutationStarted = false; let runtimeMutationStarted = false; let stateCommitStarted = false; try { - configMutationStarted = true; - dependencies.config.write(candidateConfig); - if (wasRunning) { + if (configChanged) { + configMutationStarted = true; + dependencies.config.write(candidateConfig); + } + if (wasRunning && configChanged) { const command = await dependencies.runtime.applyCommand(); runtimeMutationStarted = command.mutationStarted; if (!command.ok) throw command.error; @@ -103,24 +112,27 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) { } }; - const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => { + const update = ( + rules: unknown, + expectedRulesRevision: unknown, + rulesContractVersion: unknown, + ) => { + if (rulesContractVersion !== ROUTE_RULES_CONTRACT_VERSION) { + throw new HarborError('REQUEST_INVALID'); + } let routeRules: RouteRule[]; try { routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[]; } catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); } - const rulesRevision = expectedRulesRevision ?? expectedRevision; - if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) { + if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) { throw new HarborError('REQUEST_INVALID'); } return dependencies.serialize(async () => { const current = dependencies.state.read(); - const currentRevision = expectedRulesRevision == null - ? current.revision - : current.routeRulesRevision; - if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT'); + if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT'); if (isDeepStrictEqual(current.routeRules, routeRules)) return; await dependencies.runOperation(() => applyRules(current, routeRules)); }); diff --git a/src/server/features/subscription/subscriptionService.ts b/src/server/features/subscription/subscriptionService.ts index 9bf58f9..967a453 100644 --- a/src/server/features/subscription/subscriptionService.ts +++ b/src/server/features/subscription/subscriptionService.ts @@ -297,6 +297,9 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen profiles: replaceProfile(current, refreshedProfile), appliedServerId: nextAppliedServerId, appliedServerSnapshot: nextAppliedServer, + appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct' + ? [] + : current.routeRules, })); } catch (error) { await finishRollback(error, [ diff --git a/src/server/http/routes/routeRulesRoute.ts b/src/server/http/routes/routeRulesRoute.ts index 22cbc77..ff9a2ce 100644 --- a/src/server/http/routes/routeRulesRoute.ts +++ b/src/server/http/routes/routeRulesRoute.ts @@ -11,9 +11,9 @@ interface RouteRulesRouteDependencies { export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) { return { async handle(req: IncomingMessage, res: ServerResponse) { - if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false; - const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req); - await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision); + if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false; + const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req); + await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion); await dependencies.sendState(res); return true; }, diff --git a/src/server/index.ts b/src/server/index.ts index 124a632..7a369ab 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -538,6 +538,10 @@ const routeRulesService = createRouteRulesService({ ), restoreRunning: () => startSingbox(), }, + route: { + isGatewayDirect: () => settings.appMode === 'client' + && gatewayAutoService.read().mode === 'gateway-direct', + }, serialize: serializeControl, runOperation: (operation) => withOperation('route-rules', operation), }); @@ -851,7 +855,10 @@ if (bootWantsRunning) { appliedProfileId: appliedProfile.id, appliedServerId: appliedServer.id, appliedServerSnapshot: appliedServer, - ...(target ? { appliedRouteRules: state.routeRules } : {}), + ...(settings.appMode === 'client' + && gatewayAutoService.read().mode === 'gateway-direct' + ? { appliedRouteRules: [] } + : target ? { appliedRouteRules: state.routeRules } : {}), })); } }) diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts index b2d9fff..794a8e1 100644 --- a/src/server/services/stateStore.ts +++ b/src/server/services/stateStore.ts @@ -5,7 +5,7 @@ import { normalizeStoredState, type PersistedState, } from '../../shared/contracts/state.js'; -import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; +import { INITIAL_ROUTE_RULES, normalizeRouteRules } from '../../shared/routingRules.js'; import { normalizeServers, resolveServerId, @@ -13,7 +13,7 @@ import { type NormalizedServer, } from '../../shared/serverIdentity.js'; -export const STATE_SCHEMA_VERSION = 5; +export const STATE_SCHEMA_VERSION = 6; export interface AtomicWriteOptions { beforeRename?: (temporaryPath: string, filePath: string) => void; @@ -143,6 +143,8 @@ export function migrateStoredState( const routeRules = version < 3 ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] : stored.routeRules; + const migratedRouteRules = normalizeRouteRules(routeRules, { strict: version >= 6 }); + const migratedAppliedRouteRules = normalizeRouteRules(stored.appliedRouteRules, { strict: version >= 6 }); const legacyCache = record(legacySubscriptionCache); const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim(); const cachedSubscriptionUrl = String(legacyCache.url || '').trim(); @@ -168,7 +170,8 @@ export function migrateStoredState( const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped'); const normalized = normalizeStoredState({ ...stored, - routeRules, + routeRules: migratedRouteRules, + appliedRouteRules: migratedAppliedRouteRules, ...(version < 5 && !Array.isArray(stored.profiles) ? { subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''), subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null, diff --git a/src/server/singbox.ts b/src/server/singbox.ts index 59405cb..16ce94a 100644 --- a/src/server/singbox.ts +++ b/src/server/singbox.ts @@ -70,9 +70,12 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk set_system_proxy: false, }, ]; - const directRules = normalizeRouteRules(routeRules) + const userRules = (directClient ? [] : normalizeRouteRules(routeRules)) .filter((rule) => rule.enabled) - .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); + .map((rule) => ({ + [rule.type]: [rule.value], + outbound: rule.outbound === 'vpn' ? vpnOutbound.tag : 'direct', + })); const rules = clientMode ? [ { @@ -82,7 +85,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk timeout: SNIFF_TIMEOUT, }, { inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag }, - ...directRules, + ...userRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ] : [ @@ -93,7 +96,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk timeout: SNIFF_TIMEOUT, }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, - ...directRules, + ...userRules, { inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ]; diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts index 1be467d..c56ec86 100644 --- a/src/shared/contracts/state.ts +++ b/src/shared/contracts/state.ts @@ -1,4 +1,8 @@ -import { normalizeRouteRules } from '../routingRules.js'; +import { + normalizeRouteRules, + ROUTE_RULES_CONTRACT_VERSION, + type RouteRuleOutbound, +} from '../routingRules.js'; import { normalizeServers, resolveServerId } from '../serverIdentity.js'; export type HarborMode = 'client' | 'gateway'; @@ -18,6 +22,7 @@ export interface RouteRule { type: 'domain' | 'domain_suffix' | 'domain_keyword'; value: string; enabled: boolean; + outbound: RouteRuleOutbound; } export interface StoredProfile { @@ -74,6 +79,7 @@ export interface StateSnapshot { lastError: string | null; }; route: { + rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION; mode: string; gatewayAddress: string | null; gatewayUiOrigin: string | null; @@ -343,7 +349,8 @@ export function createStateSnapshot({ : routeMode === 'gateway-direct' ? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found' : gatewayAuto?.lastError ? 'gateway-lost' : 'local'; - const activeLocalRules = running ? stored.appliedRouteRules : []; + const localRulesBypassed = mode === 'client' && routeMode === 'gateway-direct'; + const activeLocalRules = running && !localRulesBypassed ? stored.appliedRouteRules : []; const appliedServerSnapshot = stored.appliedServerSnapshot; return assertStateSnapshot({ @@ -377,6 +384,7 @@ export function createStateSnapshot({ lastError: null, }, route: { + rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION, mode: routeMode, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null, @@ -387,7 +395,9 @@ export function createStateSnapshot({ localRules: stored.routeRules, activeLocalRules, localRulesRevision: stored.routeRulesRevision, - localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules), + localRulesPendingRestart: localRulesBypassed + ? false + : !isSameRules(stored.routeRules, activeLocalRules), }, operation: { kind: nullableText(operation.kind), @@ -402,7 +412,24 @@ export function createStateSnapshot({ } export function assertStateSnapshot(snapshot: unknown): StateSnapshot { - const candidate = snapshot as StateSnapshot; + const rawCandidate = snapshot as StateSnapshot; + const legacyRule = (rule: RouteRule): RouteRule => ( + rule && !Object.hasOwn(rule, 'outbound') + ? { ...rule, outbound: 'direct' } + : rule + ); + const candidate = rawCandidate?.route?.rulesContractVersion === undefined + && Array.isArray(rawCandidate?.route?.localRules) + && Array.isArray(rawCandidate?.route?.activeLocalRules) + ? { + ...rawCandidate, + route: { + ...rawCandidate.route, + localRules: rawCandidate.route.localRules.map(legacyRule), + activeLocalRules: rawCandidate.route.activeLocalRules.map(legacyRule), + }, + } + : rawCandidate; const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value)); const nullableDate = (value: unknown) => value === null || validDate(value); const nullableString = (value: unknown) => value === null || typeof value === 'string'; @@ -420,7 +447,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) && typeof rule.value === 'string' && Boolean(rule.value) && - typeof rule.enabled === 'boolean' + typeof rule.enabled === 'boolean' && + ['vpn', 'direct'].includes(rule.outbound) ); const validProfile = (profile: ProfileSnapshot) => ( profile && @@ -469,6 +497,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { !nullableDate(candidate.connection.startedAt) || !nullableString(candidate.connection.lastError) || !candidate.route || + ![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) || typeof candidate.route.mode !== 'string' || !nullableString(candidate.route.gatewayAddress) || !nullableString(candidate.route.gatewayUiOrigin) || diff --git a/src/shared/routingRules.ts b/src/shared/routingRules.ts index 9eb8e89..58192c3 100644 --- a/src/shared/routingRules.ts +++ b/src/shared/routingRules.ts @@ -1,16 +1,20 @@ export const INITIAL_ROUTE_RULES = Object.freeze([ - Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }), + Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }), ]); const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']); +const RULE_OUTBOUNDS = new Set(['vpn', 'direct']); +export const ROUTE_RULES_CONTRACT_VERSION = 2; export const MAX_ROUTE_RULES = 200; export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword'; +export type RouteRuleOutbound = 'vpn' | 'direct'; export interface NormalizedRouteRule { type: RouteRuleType; value: string; enabled: boolean; + outbound: RouteRuleOutbound; } function record(value: unknown): Record { @@ -28,20 +32,32 @@ function hostname(value: unknown) { return normalized; } -function normalizeRule(input: unknown): NormalizedRouteRule { +function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule { const rule = record(input); const type = String(rule.type || '').trim(); if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type'); if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') { throw new TypeError('Invalid domain rule enabled state'); } + if (strict && !Object.hasOwn(rule, 'outbound')) { + throw new TypeError('Route rule outbound is required'); + } + const outbound = Object.hasOwn(rule, 'outbound') + ? String(rule.outbound || '').trim() + : 'direct'; + if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound'); const value = type === 'domain_keyword' ? String(rule.value || '').trim().toLowerCase() : hostname(rule.value); if (!value || value.length > 253 || /[\s/:?#]/.test(value)) { throw new TypeError('Invalid domain rule value'); } - return { type: type as RouteRuleType, value, enabled: rule.enabled !== false }; + return { + type: type as RouteRuleType, + value, + enabled: rule.enabled !== false, + outbound: outbound as RouteRuleOutbound, + }; } export function normalizeRouteRules( @@ -60,7 +76,7 @@ export function normalizeRouteRules( const normalized: NormalizedRouteRule[] = []; for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { try { - const rule = normalizeRule(candidate); + const rule = normalizeRule(candidate, strict); const key = `${rule.type}:${rule.value}`; if (seen.has(key)) continue; seen.add(key); diff --git a/src/shared/versions.ts b/src/shared/versions.ts index b024597..feb7455 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.16', - gatewayClient: '0.26.15', - gatewayBackend: '0.26.5', + macClient: '0.26.0', + gatewayClient: '0.27.0', + gatewayBackend: '0.27.0', }); export interface ParsedVersion { diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index 9b565bc..c39c6b5 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -1,5 +1,6 @@ import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js'; import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js'; +import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js'; type RequestOptions = Omit & { headers?: Record; @@ -148,9 +149,9 @@ export const api = { }), }, routeRules: { - update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', { + update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules/v2', { method: 'PUT', - body: JSON.stringify({ rules, expectedRulesRevision }), + body: JSON.stringify({ rules, expectedRulesRevision, rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION }), }), }, devices: { diff --git a/src/web/features/routing/RoutingFeature.tsx b/src/web/features/routing/RoutingFeature.tsx index 543bf89..12e665a 100644 --- a/src/web/features/routing/RoutingFeature.tsx +++ b/src/web/features/routing/RoutingFeature.tsx @@ -2,16 +2,37 @@ import { useEffect, useRef, useState, + type FocusEvent as ReactFocusEvent, type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react'; import { flushSync } from 'react-dom'; -import { canAppendRouteRule } from '../../../shared/routingRules.js'; +import { + canAppendRouteRule, + ROUTE_RULES_CONTRACT_VERSION, +} from '../../../shared/routingRules.js'; import type { RouteRule } from '../../../shared/contracts/state.js'; import { operationBlocked } from '../../state/operations.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { Drawer } from '../../ui/Drawer.js'; import { RailAction } from '../../ui/RailAction.js'; +import { + beginRuleReorder, + crossedRuleIndex, + edgeScrollDelta, + endRuleReorder, + keyboardRuleIndex, + moveRule, + RULE_DROP_DURATION_MS, + RULE_REORDER_DURATION_MS, + restoreRuleOrder, + shouldLiftRule, + type RuleReorderEndReason, + type RuleReorderLifecycle, +} from './ruleReorderModel.js'; const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [ ['domain', 'Точный домен'], @@ -31,6 +52,8 @@ interface DraftRule extends RouteRule { } interface RoutingState { + rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION; + mode?: string; localRules?: RouteRule[]; activeLocalRules?: RouteRule[]; localRulesRevision?: number; @@ -50,39 +73,57 @@ interface RoutingSaveState { localRulesPendingRestart: boolean; } +interface RuleDragSession extends RuleReorderLifecycle { + originRules: DraftRule[]; + pointerId: number | null; + startY: number; + latestY: number; + pointerOffsetY: number; + translateY: number; + raf: number | null; + handle: HTMLButtonElement; +} + let localRuleDraftId = 0; const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({ ...rule, enabled: rule?.enabled !== false, + outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct', _key: `route-rule-${localRuleDraftId += 1}`, }); const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules .filter((rule) => !rule.removing) - .map(({ type, value, enabled }) => ({ type, value, enabled })); + .map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })); const localRulesSignature = (rules: Array) => JSON.stringify( rules .filter((rule) => !rule.removing) - .map(({ type, value, enabled }) => ({ type, value, enabled })), + .map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })), ); -const localRuleKey = ({ type, value, enabled }: RouteRule) => ( - `${type}:${String(value || '').trim().toLowerCase()}:${enabled}` +const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => ( + `${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}` +); + +const sameRule = (left?: RouteRule, right?: RouteRule) => ( + Boolean(left && right && localRuleKey(left) === localRuleKey(right)) ); function localRuleStatus( rule: DraftRule, + index: number, savedRules: RouteRule[], activeRules: RouteRule[], runtimeActive: boolean, + bypassed: boolean, ) { - const key = localRuleKey(rule); - if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено']; + if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено']; if (!rule.enabled) return ['disabled', 'Выключено']; + if (bypassed) return ['bypassed', 'Обходится']; if (!runtimeActive) return ['saved', 'Сохранено']; - if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно']; + if (sameRule(rule, activeRules[index])) return ['active', 'Активно']; return ['pending', 'Ждёт перезапуска']; } @@ -121,19 +162,34 @@ export function useRoutingFeature({ const [rules, setRules] = useState([]); const [revision, setRevision] = useState(route?.localRulesRevision || 0); const [confirmingClose, setConfirmingClose] = useState(false); + const [liftedKey, setLiftedKey] = useState(null); + const [reorderAnnouncement, setReorderAnnouncement] = useState(''); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); const baselineRef = useRef('[]'); + const rulesRef = useRef(rules); + const dragRef = useRef(null); + const suppressHandleClickRef = useRef(false); + rulesRef.current = rules; const savedRules = route?.localRules || []; const activeRules = route?.activeLocalRules || []; + const editable = route?.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION; + const bypassed = route?.mode === 'gateway-direct'; const dirty = localRulesSignature(rules) !== baselineRef.current; - const pendingRestart = connected && route?.localRulesPendingRestart === true; + const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true; + const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true; const pendingCount = pendingRestart - ? savedRules.filter((rule) => ( - rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule)) - )).length + ? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length : 0; + const reorderedOnly = pendingRestart + && savedRules.length === activeRules.length + && JSON.stringify(savedRules.map(localRuleKey).sort()) === JSON.stringify(activeRules.map(localRuleKey).sort()); + const pendingMessage = !pendingRestart + ? '' + : reorderedOnly || pendingCount === 0 + ? 'Изменения правил не применены' + : `${pendingCount} ${pendingCount === 1 ? 'правило не применено' : 'правила не применены'}`; const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing); useEffect(() => { @@ -153,6 +209,11 @@ export function useRoutingFeature({ if (event.type === 'keydown') { const keyboardEvent = event as KeyboardEvent; if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return; + if (dragRef.current?.lifted) { + keyboardEvent.preventDefault(); + cancelReorder(); + return; + } } else { if (panelRef.current?.contains(event.target as Node)) return; if (toggleRef.current?.contains(event.target as Node)) return; @@ -167,6 +228,14 @@ export function useRoutingFeature({ }; }, [isOpen, dirty]); + useEffect(() => () => { + const session = dragRef.current; + if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) { + cancelAnimationFrame(session.raf); + } + dragRef.current = null; + }, []); + useEffect(() => { if (!isOpen || !dirty) return undefined; const warnBeforeUnload = (event: BeforeUnloadEvent) => { @@ -178,8 +247,10 @@ export function useRoutingFeature({ }, [isOpen, dirty]); function open() { - baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled }))); - setRules(savedRules.map(createLocalRuleDraft)); + const nextRules = savedRules.map(createLocalRuleDraft); + baselineRef.current = localRulesSignature(savedRules); + rulesRef.current = nextRules; + setRules(nextRules); setRevision(route?.localRulesRevision || 0); setConfirmingClose(false); onDismissError(); @@ -187,11 +258,13 @@ export function useRoutingFeature({ } function forceClose() { + cancelReorder(false); setIsOpen(false); } function requestClose() { - if (dirty) { + const currentRules = cancelReorder(false); + if (localRulesSignature(currentRules) !== baselineRef.current) { setConfirmingClose(true); return false; } @@ -200,36 +273,52 @@ export function useRoutingFeature({ } function discard() { + cancelReorder(false); setConfirmingClose(false); setIsOpen(false); } - function change(index: number, field: keyof Pick, value: unknown) { - setRules((current) => current.map((rule, ruleIndex) => ( + function updateRules(updater: (current: DraftRule[]) => DraftRule[]) { + setRules((current) => { + const next = updater(current); + rulesRef.current = next; + return next; + }); + } + + function change( + index: number, + field: keyof Pick, + value: unknown, + ) { + if (!editable || blocked) return; + updateRules((current) => current.map((rule, ruleIndex) => ( ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule ))); } function add() { - setRules((current) => [ + if (!editable || blocked) return; + updateRules((current) => [ ...current, - createLocalRuleDraft({ type: 'domain', value: '', enabled: true }), + createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }), ]); } function remove(ruleKey: string) { + if (!editable || blocked) return; if (matchMedia('(prefers-reduced-motion: reduce)').matches) { - setRules((current) => current.filter((rule) => rule._key !== ruleKey)); + updateRules((current) => current.filter((rule) => rule._key !== ruleKey)); return; } - setRules((current) => current.map((rule) => ( + updateRules((current) => current.map((rule) => ( rule._key === ruleKey ? { ...rule, removing: true } : rule ))); } function finishRemove(ruleKey: string) { const update = () => flushSync(() => { - setRules((current) => current.filter((rule) => rule._key !== ruleKey)); + updateRules((current) => current.filter((rule) => rule._key !== ruleKey)); }); if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) { update(); @@ -240,25 +329,298 @@ export function useRoutingFeature({ async function save(event: FormEvent) { event.preventDefault(); - const values = localRuleValues(rules); + if (!editable || blocked) return; + if (dragRef.current) dropReorder(false); + const values = localRuleValues(rulesRef.current); const result = routingSaveState(await onSave(values, revision)); if (!result) return; - baselineRef.current = JSON.stringify(values); + baselineRef.current = localRulesSignature(values); setRevision(result.localRulesRevision); setConfirmingClose(false); if (!connected || !result.localRulesPendingRestart) setIsOpen(false); } + function reducedMotion() { + return matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + function ruleRows() { + return new Map(Array.from( + panelRef.current?.querySelectorAll('[data-rule-key]') || [], + (row) => [row.dataset.ruleKey || '', row], + )); + } + + function replaceRuleOrder(next: DraftRule[], draggedRuleKey: string) { + const rows = ruleRows(); + const previousTops = new Map(Array.from(rows, ([key, row]) => [key, row.getBoundingClientRect().top])); + rulesRef.current = next; + flushSync(() => setRules(next)); + if (reducedMotion()) return; + for (const [key, row] of ruleRows()) { + if (key === draggedRuleKey) continue; + const previousTop = previousTops.get(key); + if (previousTop === undefined) continue; + const delta = previousTop - row.getBoundingClientRect().top; + if (!delta) continue; + row.animate( + [{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }], + { duration: RULE_REORDER_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ); + } + } + + function moveRuleTo(ruleKey: string, target: number) { + const current = rulesRef.current; + const from = current.findIndex((rule) => rule._key === ruleKey); + if (from < 0 || target === from) return from; + replaceRuleOrder(moveRule(current, from, target), ruleKey); + return target; + } + + function positionPointerRule(pointerY: number) { + const session = dragRef.current; + if (!session?.lifted) return; + const rows = ruleRows(); + let row = rows.get(session.key); + if (!row) return; + let rect = row.getBoundingClientRect(); + const desiredCenter = pointerY - session.pointerOffsetY; + const baseCenter = rect.top + rect.height / 2 - session.translateY; + session.translateY = desiredCenter - baseCenter; + row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`); + + const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key); + const centers = rulesRef.current.map((rule) => { + const ruleRect = rows.get(rule._key)?.getBoundingClientRect(); + return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter; + }); + const target = crossedRuleIndex(currentIndex, desiredCenter, centers); + if (target === currentIndex) return; + moveRuleTo(session.key, target); + row = ruleRows().get(session.key); + if (!row) return; + rect = row.getBoundingClientRect(); + session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY); + row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`); + setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`); + } + + function stopAutoScroll(session: RuleDragSession) { + if (session.raf !== null) cancelAnimationFrame(session.raf); + session.raf = null; + } + + function continueAutoScroll(session: RuleDragSession) { + if (session.raf !== null) return; + const tick = () => { + session.raf = null; + if (dragRef.current !== session || !session.lifted) return; + const container = panelRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const delta = edgeScrollDelta(session.latestY, rect.top, rect.bottom); + if (!delta) return; + const previousTop = container.scrollTop; + container.scrollTop += delta; + if (container.scrollTop === previousTop) return; + positionPointerRule(session.latestY); + session.raf = requestAnimationFrame(tick); + }; + session.raf = requestAnimationFrame(tick); + } + + function startPointerReorder(event: ReactPointerEvent, ruleKey: string) { + if (event.button !== 0 || !event.isPrimary || !editable || blocked || rulesRef.current.length < 2) return; + if (dragRef.current?.input === 'keyboard') finishReorder('focus-leave'); + const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer'); + if (!lifecycle) return; + const row = event.currentTarget.closest('[data-rule-key]'); + if (!row) return; + const rect = row.getBoundingClientRect(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + ...lifecycle, + originRules: [...rulesRef.current], + pointerId: event.pointerId, + startY: event.clientY, + latestY: event.clientY, + pointerOffsetY: event.clientY - (rect.top + rect.height / 2), + translateY: 0, + raf: null, + handle: event.currentTarget, + }; + } + + function movePointerReorder(event: ReactPointerEvent) { + const session = dragRef.current; + if (!session || session.pointerId !== event.pointerId) return; + session.latestY = event.clientY; + if (!session.lifted) { + if (!shouldLiftRule(session.startY, event.clientY)) return; + event.preventDefault(); + session.lifted = true; + flushSync(() => setLiftedKey(session.key)); + setReorderAnnouncement('Правило поднято'); + } + event.preventDefault(); + positionPointerRule(event.clientY); + const container = panelRef.current; + const bounds = container?.getBoundingClientRect(); + if (!bounds || !edgeScrollDelta(session.latestY, bounds.top, bounds.bottom)) { + stopAutoScroll(session); + } else { + continueAutoScroll(session); + } + } + + function clearDraggedStyle(session: RuleDragSession, settle: boolean) { + const row = ruleRows().get(session.key); + if (!row) return; + const translateY = session.translateY; + row.style.removeProperty('--client-rule-drag-y'); + if (settle && translateY && !reducedMotion()) { + row.animate( + [{ transform: `translateY(${translateY}px)` }, { transform: 'translateY(0)' }], + { duration: RULE_DROP_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ); + } + } + + function finishReorder(reason: RuleReorderEndReason, announce = true) { + const session = dragRef.current; + if (!session) return rulesRef.current; + const outcome = endRuleReorder(session, reason, reducedMotion()); + if (outcome.stopAutoScroll) stopAutoScroll(session); + dragRef.current = null; + let nextRules = rulesRef.current; + if (outcome.restoreOrder) { + nextRules = restoreRuleOrder(session.originRules, rulesRef.current, (rule) => rule._key); + replaceRuleOrder(nextRules, session.key); + } + clearDraggedStyle(session, outcome.animateDrop); + setLiftedKey(null); + if ( + outcome.releasePointerCapture + && session.pointerId !== null + && session.handle.hasPointerCapture(session.pointerId) + ) { + session.handle.releasePointerCapture(session.pointerId); + } + if (session.lifted) { + if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true; + if (announce && reason !== 'unmount') { + setReorderAnnouncement(reason === 'drop' ? 'Правило размещено' : 'Перемещение отменено'); + } + } + if (outcome.restoreFocus) session.handle.focus(); + return nextRules; + } + + function dropReorder(announce = true) { + return finishReorder('drop', announce); + } + + function cancelReorder(announce = true) { + return finishReorder('cancel', announce); + } + + function finishPointerReorder(event: ReactPointerEvent) { + if (dragRef.current?.pointerId !== event.pointerId) return; + dropReorder(); + } + + function cancelPointerReorder(event: ReactPointerEvent) { + if (dragRef.current?.pointerId !== event.pointerId) return; + cancelReorder(); + } + + function losePointerReorder(event: ReactPointerEvent) { + if (dragRef.current?.pointerId !== event.pointerId) return; + finishReorder('lost-capture'); + } + + function toggleKeyboardReorder(handle: HTMLButtonElement, ruleKey: string) { + const active = dragRef.current; + if (active?.key === ruleKey && active.input === 'keyboard') { + dropReorder(); + return; + } + if (active?.input === 'keyboard') finishReorder('focus-leave'); + const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'keyboard'); + if (!lifecycle || !editable || blocked || rulesRef.current.length < 2) return; + dragRef.current = { + ...lifecycle, + originRules: [...rulesRef.current], + pointerId: null, + startY: 0, + latestY: 0, + pointerOffsetY: 0, + translateY: 0, + raf: null, + handle, + }; + setLiftedKey(ruleKey); + setReorderAnnouncement('Правило поднято'); + } + + function handleReorderKey(event: ReactKeyboardEvent, ruleKey: string) { + const session = dragRef.current; + const ownsSession = session?.key === ruleKey && session.input === 'keyboard'; + if (!ownsSession) return; + if (event.key === 'Tab') { + finishReorder('focus-leave'); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + cancelReorder(); + return; + } + if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + const current = rulesRef.current.findIndex((rule) => rule._key === ruleKey); + const target = keyboardRuleIndex(current, event.key === 'ArrowUp' ? -1 : 1, rulesRef.current.length); + if (target === current) return; + moveRuleTo(ruleKey, target); + setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`); + event.currentTarget.focus(); + } + + function handleReorderClick(event: ReactMouseEvent, ruleKey: string) { + if (suppressHandleClickRef.current) { + suppressHandleClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.detail === 0) toggleKeyboardReorder(event.currentTarget, ruleKey); + } + + function handleReorderBlur(event: ReactFocusEvent, ruleKey: string) { + const session = dragRef.current; + if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) { + finishReorder('focus-leave'); + } + } + return { isOpen, rules, savedRules, activeRules, connected, + editable, + bypassed, dirty, pendingRestart, + savedUntilStart, pendingCount, + pendingMessage, blocked, + liftedKey, + reorderAnnouncement, confirmingClose, panelRef, toggleRef, @@ -272,6 +634,14 @@ export function useRoutingFeature({ add, remove, finishRemove, + startPointerReorder, + movePointerReorder, + finishPointerReorder, + cancelPointerReorder, + losePointerReorder, + handleReorderKey, + handleReorderClick, + handleReorderBlur, save, }; } @@ -379,6 +749,32 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypeP ); } +function RuleOutboundPicker({ + value, + index, + disabled, + onChange, +}: { + value: RouteRule['outbound']; + index: number; + disabled?: boolean; + onChange: (value: RouteRule['outbound']) => void; +}) { + return
+ {([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => ( + + ))} +
; +} + export function RoutingToggle({ feature, open, @@ -394,7 +790,7 @@ export function RoutingToggle({ hasSubscription: boolean; onOpen: () => void; }) { - const disabled = gatewayDirect || (isGateway && !hasSubscription); + const disabled = isGateway && !hasSubscription; return ( open ? feature.requestClose() : onOpen()} >
- {feature.pendingCount > 0 && ( + return
+ {feature.pendingMessage && ( <> - {feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'} + {feature.pendingMessage} )} @@ -446,7 +840,8 @@ export function RoutingPendingStatus({ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) { const draftRules = feature.rules.filter((rule) => !rule.removing); - const canAdd = canAppendRouteRule(draftRules) && !feature.blocked; + const editorDisabled = feature.blocked || !feature.editable; + const canAdd = canAppendRouteRule(draftRules) && !editorDisabled; const incomplete = draftRules.some((rule) => !String(rule.value || '').trim()); return ( @@ -458,7 +853,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; sheetClassName="client-local-rules-sheet" open={feature.isOpen} labelledBy="local-rules-title" - closeLabel="Закрыть локальные правила" + closeLabel="Закрыть правила маршрутизации" onClose={feature.requestClose} >
@@ -467,43 +862,92 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; className="client-local-rules-save" type="submit" form="client-local-rules-form" - disabled={feature.blocked || !feature.dirty} + disabled={editorDisabled || !feature.dirty} > Сохранить -

Локальные правила

-

Эти домены идут напрямую. Остальной трафик — через выбранный VPN.

- {feature.pendingRestart && ( +

Правила маршрутизации

+

Проверяются сверху вниз. Первое совпадение выбирает маршрут.

+ {!feature.editable && ( +

+ Обновите Harbor, чтобы редактировать правила. +

+ )} + {feature.bypassed && ( +

+ Локальный список сейчас обходится Harbor Gateway. +

+ )} + {!feature.bypassed && feature.pendingRestart && (

Правила сохранены, но начнут работать после запуска или перезапуска sing-box.

)} + {feature.savedUntilStart && ( +

+ Правила сохранены и начнут работать после запуска Harbor. +

+ )}
+ + Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет. + + + {feature.reorderAnnouncement} +
Правила
{feature.rules.map((rule, index) => { const [status, statusLabel] = localRuleStatus( rule, + index, feature.savedRules, feature.activeRules, feature.connected, + feature.bypassed, ); + const lifted = feature.liftedKey === rule._key; + const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2; return (
+
); })} - {!feature.rules.length &&

Правил пока нет. Весь трафик идёт через VPN.

} + {!feature.rules.length &&

Правил пока нет. Harbor использует выбранный режим соединения.

}
+

+ Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения. +

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

diff --git a/src/web/features/routing/ruleReorderModel.ts b/src/web/features/routing/ruleReorderModel.ts new file mode 100644 index 0000000..a7c2491 --- /dev/null +++ b/src/web/features/routing/ruleReorderModel.ts @@ -0,0 +1,86 @@ +export const RULE_DRAG_THRESHOLD_PX = 4; +export const RULE_EDGE_ZONE_PX = 32; +export const RULE_REORDER_DURATION_MS = 220; +export const RULE_DROP_DURATION_MS = 260; + +export type RuleReorderInput = 'pointer' | 'keyboard'; +export type RuleReorderEndReason = 'drop' | 'cancel' | 'focus-leave' | 'lost-capture' | 'unmount'; + +export interface RuleReorderLifecycle { + key: string; + input: RuleReorderInput; + lifted: boolean; +} + +export function beginRuleReorder( + current: RuleReorderLifecycle | null, + key: string, + input: RuleReorderInput, +): RuleReorderLifecycle | null { + if (current) return null; + return { key, input, lifted: input === 'keyboard' }; +} + +export function endRuleReorder( + session: RuleReorderLifecycle, + reason: RuleReorderEndReason, + reducedMotion = false, +) { + return { + restoreOrder: reason !== 'drop' && session.lifted, + stopAutoScroll: true as const, + restoreFocus: !['focus-leave', 'unmount'].includes(reason), + releasePointerCapture: session.input === 'pointer' && !['lost-capture', 'unmount'].includes(reason), + animateDrop: reason === 'drop' && session.lifted && !reducedMotion, + }; +} + +export function shouldLiftRule(startY: number, currentY: number) { + return Math.abs(currentY - startY) >= RULE_DRAG_THRESHOLD_PX; +} + +export function moveRule(rules: readonly T[], from: number, to: number): T[] { + if (from === to || from < 0 || to < 0 || from >= rules.length || to >= rules.length) { + return [...rules]; + } + const next = [...rules]; + const [rule] = next.splice(from, 1); + next.splice(to, 0, rule); + return next; +} + +export function restoreRuleOrder( + origin: readonly T[], + current: readonly T[], + keyOf: (item: T) => string, +): T[] { + const currentByKey = new Map(current.map((item) => [keyOf(item), item])); + const originKeys = new Set(origin.map(keyOf)); + return [ + ...origin.map((item) => currentByKey.get(keyOf(item))).filter((item): item is T => item !== undefined), + ...current.filter((item) => !originKeys.has(keyOf(item))), + ]; +} + +export function crossedRuleIndex(current: number, draggedCenterY: number, centers: readonly number[]) { + let target = current; + while (target > 0 && draggedCenterY < centers[target - 1]) target -= 1; + while (target < centers.length - 1 && draggedCenterY > centers[target + 1]) target += 1; + return target; +} + +export function keyboardRuleIndex(current: number, direction: -1 | 1, length: number) { + return Math.max(0, Math.min(length - 1, current + direction)); +} + +export function edgeScrollDelta( + pointerY: number, + top: number, + bottom: number, + zone = RULE_EDGE_ZONE_PX, +) { + const edgeDelta = (distance: number) => Math.min(12, Math.max(2, 12 - (distance / zone) * 10)); + if (pointerY >= top && pointerY < top + zone) return -edgeDelta(pointerY - top); + if (pointerY <= bottom && pointerY > bottom - zone) return edgeDelta(bottom - pointerY); + return 0; +} diff --git a/src/web/styles/features/routing.css b/src/web/styles/features/routing.css index 67812e7..9aa1ab9 100644 --- a/src/web/styles/features/routing.css +++ b/src/web/styles/features/routing.css @@ -76,16 +76,18 @@ } .client-local-rule { + --client-rule-drag-y: 0px; position: relative; isolation: isolate; min-width: 0; display: grid; - grid-template-columns: 24px 116px minmax(0, 1fr) 112px 28px; + grid-template-columns: 44px 24px 116px minmax(0, 1fr) 28px; align-items: center; - gap: 10px; + column-gap: 8px; + row-gap: 2px; padding: 7px 0; animation: client-row-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both; - transition: opacity 260ms ease, filter 360ms ease; + transition: background-color 180ms ease, box-shadow 180ms ease, opacity 260ms ease, filter 360ms ease; } .client-local-rule:has(.client-rule-type.is-open) { @@ -98,6 +100,7 @@ letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); text-align: right; + white-space: nowrap; transition: color 280ms ease, opacity 280ms ease, filter 360ms ease; } @@ -110,11 +113,19 @@ color: var(--client-warning, oklch(0.72 0.12 72)); } -.client-local-rule.is-disabled { +.client-local-rule.is-disabled > :not(.client-rule-handle):not(.client-row-delete):not(.client-delete-strike) { opacity: 0.42; filter: saturate(0); } +.client-local-rule.is-dragging { + z-index: 6; + animation: none; + background: color-mix(in oklch, var(--client-accent) 5%, transparent); + box-shadow: 0 10px 28px color-mix(in oklch, var(--client-accent) 12%, transparent); + transform: translateY(var(--client-rule-drag-y)); +} + .client-local-rule.is-removing { pointer-events: none; animation: client-row-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both; @@ -132,6 +143,44 @@ cursor: pointer; } +.client-rule-handle { + width: 44px; + height: 44px; + display: grid; + place-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--client-muted); + cursor: grab; + touch-action: none; + transition: color 180ms ease, filter 180ms ease; +} + +.client-rule-handle svg { + width: 12px; + height: 28px; + overflow: visible; + fill: currentColor; +} + +.client-rule-handle:hover:not(:disabled), +.client-rule-handle:focus-visible, +.client-rule-handle[aria-pressed='true'] { + outline: 0; + color: var(--client-accent); + filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 48%, transparent)); +} + +.client-rule-handle[aria-pressed='true'] { + cursor: grabbing; +} + +.client-rule-handle:disabled { + opacity: 0.28; + cursor: default; +} + .client-local-rule-enabled svg { width: 18px; height: 18px; @@ -312,6 +361,67 @@ text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent); } +.client-local-rule-meta { + min-width: 0; + grid-column: 2 / -1; + grid-row: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.client-rule-outbound { + width: 128px; + height: 30px; + flex: 0 0 128px; + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; +} + +.client-rule-outbound button { + height: 30px; + padding: 0 5px; + border: 0; + background: transparent; + color: var(--client-muted); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); + cursor: pointer; + transition: color 280ms ease, filter 280ms ease, text-shadow 280ms ease; +} + +.client-rule-outbound button[aria-pressed='true'] { + color: var(--client-accent); + filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 34%, transparent)); + text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 28%, transparent); +} + +.client-rule-outbound button:disabled { + cursor: default; +} + +.client-rule-outbound button:focus-visible { + outline: 0; + color: var(--client-accent); + text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 42%, transparent); +} + +.client-rule-reorder-instructions, +.client-rule-reorder-live { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .client-local-rules-empty { padding: 14px 4px 4px; color: var(--client-muted); diff --git a/src/web/styles/layout.css b/src/web/styles/layout.css index be9cc4d..21c5f1e 100644 --- a/src/web/styles/layout.css +++ b/src/web/styles/layout.css @@ -629,15 +629,6 @@ } @media (max-width: 560px) { - .client-local-rule { - grid-template-columns: 24px 104px minmax(0, 1fr) 28px; - } - - .client-local-rule-status { - grid-column: 3; - text-align: left; - } - .client-mode .app-main { padding: 20px 14px; } @@ -713,15 +704,27 @@ } .client-local-rule { - grid-template-columns: 44px minmax(0, 1fr) 44px; + grid-template-columns: 44px 44px minmax(0, 1fr) 44px; } + .client-rule-handle, .client-local-rule-enabled, .client-row-delete { width: 44px; height: 44px; } + .client-rule-type-trigger, + .client-local-rule input, + .client-rule-outbound, + .client-rule-outbound button { + height: 44px; + } + + .client-rule-type-list button { + min-height: 44px; + } + .client-instructions-sheet { padding: 40px 58px 60px 18px; } @@ -734,26 +737,36 @@ gap: 8px; } - .client-local-rule-enabled { + .client-rule-handle { grid-column: 1; grid-row: 1; } - .client-rule-type { + .client-local-rule-enabled { grid-column: 2; grid-row: 1; } - .client-row-delete { + .client-rule-type { grid-column: 3; grid-row: 1; } + .client-row-delete { + grid-column: 4; + grid-row: 1; + } + .client-local-rule input { grid-column: 2 / -1; grid-row: 2; } + .client-local-rule-meta { + grid-column: 2 / -1; + grid-row: 3; + } + .client-instruction-block:nth-child(n) { margin-inline: 0; } @@ -769,3 +782,23 @@ } } + +@media (max-width: 360px) { + .client-local-rule-meta { + flex-wrap: wrap; + justify-content: flex-start; + } + + .client-rule-outbound { + width: 100%; + flex-basis: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .client-local-rule-status { + width: 100%; + min-height: 1em; + text-align: left; + white-space: normal; + } +} diff --git a/src/web/styles/primitives.css b/src/web/styles/primitives.css index a22b611..e12b46a 100644 --- a/src/web/styles/primitives.css +++ b/src/web/styles/primitives.css @@ -314,8 +314,10 @@ transform: rotate(8deg) scale(1.1); } +.client-rule-handle:focus-visible, .client-local-rule-enabled:focus-visible, .client-rule-type-trigger:focus-visible, +.client-rule-outbound button:focus-visible, .client-row-delete:focus-visible, .client-row-add:focus-visible, .client-local-rules-save:focus-visible, diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index 7be52c7..e5bd9ad 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -122,6 +122,7 @@ } .client-local-rule, + .client-rule-handle, .client-local-rule-enabled svg, .client-local-rule-enabled circle, .client-local-rule-enabled path, @@ -130,6 +131,7 @@ .client-rule-type-list, .client-rule-type-list button, .client-local-rule input, + .client-rule-outbound button, .client-row-delete, .client-row-add, .client-row-add-slot > span, diff --git a/test/server/connection-service.test.js b/test/server/connection-service.test.js index 6db5495..ecabbd3 100644 --- a/test/server/connection-service.test.js +++ b/test/server/connection-service.test.js @@ -12,7 +12,7 @@ import { HarborError } from '../../dist/shared/errors.js'; const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' }; const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' }; -const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }]; +const rules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }]; const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({ id, @@ -148,6 +148,7 @@ test('apply validates the profile pair and publishes applied identity only after assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a'); assert.equal(after.state.appliedProfileId, 'work'); assert.equal(after.state.appliedServerId, 'a'); + assert.deepEqual(after.state.appliedRouteRules, rules); assert.deepEqual(after.state.appliedServerSnapshot, serverA); assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']); @@ -197,11 +198,14 @@ test('activate only changes desired while stopped and transactionally switches w }); test('gateway-direct changes only local desired state and never claims a remote applied target', async () => { - const harness = createHarness({ gatewayDirect: true }); + const state = initialState(); + state.appliedRouteRules = []; + const harness = createHarness({ gatewayDirect: true, state }); await harness.service.apply('work', 'b'); const after = harness.snapshot(); assert.equal(after.state.desiredProfileId, 'work'); assert.equal(after.state.appliedProfileId, 'personal'); + assert.deepEqual(after.state.appliedRouteRules, []); assert.equal(harness.events.includes('config.write'), false); assert.equal(harness.events.includes('runtime.start'), false); }); @@ -232,6 +236,15 @@ test('restart uses applied pair while running and desired pair while stopped', a assert.equal(stopped.snapshot().state.appliedProfileId, 'work'); assert.equal(stopped.snapshot().state.appliedServerId, 'b'); assert.match(stopped.snapshot().config, /"selectedServerId":"b"/); + assert.deepEqual(stopped.snapshot().state.appliedRouteRules, rules); +}); + +test('restart in gateway-direct keeps user rules omitted from applied truth', async () => { + const state = initialState(); + state.appliedRouteRules = []; + const harness = createHarness({ gatewayDirect: true, state }); + await harness.service.restart(); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, []); }); test('running restart preserves a pending desired profile while restoring the applied pair', async () => { diff --git a/test/server/gateway-auto-service.test.js b/test/server/gateway-auto-service.test.js index 0518487..4d12e3a 100644 --- a/test/server/gateway-auto-service.test.js +++ b/test/server/gateway-auto-service.test.js @@ -27,6 +27,10 @@ const storedProfile = (servers = [server]) => ({ lastRefreshAttemptAt: null, lastRefreshErrorCode: null, }); +const routeRules = [ + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, +]; const canonicalState = (profiles = [storedProfile()]) => ({ revision: 10, profiles, @@ -34,8 +38,8 @@ const canonicalState = (profiles = [storedProfile()]) => ({ appliedProfileId: profiles[0]?.id || '', appliedServerId: profiles[0]?.desiredServerId || '', appliedServerSnapshot: profiles[0]?.servers[0] || null, - routeRules: [], - appliedRouteRules: [], + routeRules, + appliedRouteRules: routeRules, routeRulesRevision: 0, gatewayAutoEnabled: true, }); @@ -277,6 +281,18 @@ test('gateway-auto startup writes candidate config before publication without ap 'state.update', 'route:gateway-direct', ]); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules); +}); + +test('gateway-auto running transitions publish the rules actually present in each mode', async () => { + const harness = createHarness({ running: true }); + await harness.service.refresh(); + assert.equal(harness.snapshot().gatewayAuto.mode, 'gateway-direct'); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, []); + + await harness.service.setEnabled(false); + assert.equal(harness.snapshot().gatewayAuto.mode, 'local-vpn'); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules); }); test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => { diff --git a/test/server/gateway-presence.test.js b/test/server/gateway-presence.test.js index 28d31f2..e0217c9 100644 --- a/test/server/gateway-presence.test.js +++ b/test/server/gateway-presence.test.js @@ -136,10 +136,17 @@ test('verified Gateway stays active through transient discovery failures', () => }); test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => { + const desiredRules = [ + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + ]; + const appliedRules = [...desiredRules].reverse(); const storedState = { revision: 1, subscriptionUrl, gatewayAutoEnabled: true, + routeRules: desiredRules, + appliedRouteRules: appliedRules, }; const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({ storedState: stored, @@ -167,10 +174,17 @@ test('canonical route distinguishes fresh, stale, lost, disabled and local state assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z'); assert.equal(found.autoEnabled, true); assert.equal(found.fallbackPreference, 'local-vpn'); + assert.equal(found.rulesContractVersion, 2); + assert.deepEqual(found.localRules, desiredRules); + assert.deepEqual(found.activeLocalRules, []); + assert.equal(found.localRulesPendingRestart, false); assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale'); assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost'); assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled'); - assert.equal(snapshot(createGatewayAutoState()).reason, 'local'); + const local = snapshot(createGatewayAutoState()); + assert.equal(local.reason, 'local'); + assert.deepEqual(local.activeLocalRules, appliedRules); + assert.equal(local.localRulesPendingRestart, true); }); test('client can ignore and restore a verified Gateway without losing discovery', () => { diff --git a/test/server/route-rules-service.test.js b/test/server/route-rules-service.test.js index dedc367..bfe0afb 100644 --- a/test/server/route-rules-service.test.js +++ b/test/server/route-rules-service.test.js @@ -5,8 +5,8 @@ import test from 'node:test'; import { createRouteRulesService } from '../../dist/server/features/routing/index.js'; import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js'; -const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }]; -const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }]; +const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true, outbound: 'direct' }]; +const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'vpn' }]; const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' }; const storedProfile = (servers = [server]) => ({ id: 'primary', @@ -78,7 +78,7 @@ function createHarness(overrides = {}) { write: (value) => { events.push('config.write'); if (failures.configWrite) throw failures.configWrite; - config = JSON.stringify(value); + config = JSON.stringify(value, null, 2); if (failures.configWriteAfter) throw failures.configWriteAfter; }, restore: (value) => { @@ -103,6 +103,7 @@ function createHarness(overrides = {}) { if (failures.runtimeRestore) throw failures.runtimeRestore; }, }, + route: { isGatewayDirect: () => overrides.gatewayDirect === true }, serialize, runOperation: async (operation) => { events.push('operation'); @@ -130,27 +131,28 @@ function assertDomainRestored(actual, expected) { test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => { const harness = createHarness(); - assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID'); - assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID'); - await assert.rejects(harness.service.update(oldRules, 1, undefined), (error) => error.code === 'STATE_CONFLICT'); + assert.throws(() => harness.service.update(newRules, 2, undefined), (error) => error.code === 'REQUEST_INVALID'); + assert.throws(() => harness.service.update('bad', 2, 2), (error) => error.code === 'REQUEST_INVALID'); + assert.throws(() => harness.service.update(newRules, -1, 2), (error) => error.code === 'REQUEST_INVALID'); + assert.throws( + () => harness.service.update([{ type: 'domain', value: 'example.com', enabled: true }], 2, 2), + (error) => error.code === 'REQUEST_INVALID', + ); + await assert.rejects(harness.service.update(oldRules, 1, 2), (error) => error.code === 'STATE_CONFLICT'); const before = harness.snapshot(); - await harness.service.update(oldRules, 2, undefined); + await harness.service.update(oldRules, 2, 2); assert.deepEqual(harness.snapshot(), before); assert.deepEqual(harness.events, []); }); -test('route rules support explicit domain revision and legacy global revision with normalization', async () => { +test('route rules use the explicit domain revision and normalize without losing target', async () => { const explicit = createHarness(); await explicit.service.update([ - { type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true }, - { type: 'domain_suffix', value: 'new.example', enabled: true }, - ], 2, undefined); + { type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' }, + ], 2, 2); assert.deepEqual(explicit.snapshot().state.routeRules, newRules); assert.equal(explicit.snapshot().state.routeRulesRevision, 3); - - const legacy = createHarness(); - await legacy.service.update(newRules, undefined, 10); - assert.deepEqual(legacy.snapshot().state.routeRules, newRules); }); test('route rules state-only path leaves config and applied rules unchanged', async () => { @@ -159,7 +161,7 @@ test('route rules state-only path leaves config and applied rules unchanged', as { ...canonicalState(), revision: 1, routeRulesRevision: 0 }, ]) { const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) }); - await harness.service.update(newRules, 0, undefined); + await harness.service.update(newRules, 0, 2); assert.deepEqual(harness.snapshot().state.routeRules, newRules); assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules); assert.equal(harness.snapshot().config, 'old-config'); @@ -169,16 +171,35 @@ test('route rules state-only path leaves config and applied rules unchanged', as test('route rules running apply updates active rules while stopped leaves them pending', async () => { const running = createHarness({ running: true }); - await running.service.update(newRules, 2, undefined); + await running.service.update(newRules, 2, 2); assert.deepEqual(running.snapshot().state.appliedRouteRules, newRules); assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']); const stopped = createHarness({ running: false }); - await stopped.service.update(newRules, 2, undefined); + await stopped.service.update(newRules, 2, 2); assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules); assert.equal(stopped.events.includes('runtime.apply'), false); }); +test('route rules in gateway-direct update desired order without touching config or runtime', async () => { + const state = { ...canonicalState(), appliedRouteRules: [] }; + const harness = createHarness({ state, gatewayDirect: true, running: true }); + await harness.service.update(newRules, 2, 2); + assert.deepEqual(harness.snapshot().state.routeRules, newRules); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, []); + assert.equal(harness.snapshot().state.routeRulesRevision, 3); + assert.equal(harness.snapshot().config, 'old-config'); + assert.deepEqual(harness.events, ['operation', 'state.update']); +}); + +test('route rules skip runtime apply when the generated config bytes are unchanged', async () => { + const config = JSON.stringify({ selectedServerId: 'server', routeRules: newRules }, null, 2); + const harness = createHarness({ running: true, config }); + await harness.service.update(newRules, 2, 2); + assert.deepEqual(harness.snapshot().state.appliedRouteRules, newRules); + assert.deepEqual(harness.events, ['operation', 'state.update']); +}); + test('route rules rollback restores config/domain and honors runtime mutation phase', async () => { for (const failures of [ { configWriteAfter: new Error('config') }, @@ -186,18 +207,18 @@ test('route rules rollback restores config/domain and honors runtime mutation ph ]) { const harness = createHarness({ failures }); const before = harness.snapshot(); - await assert.rejects(harness.service.update(newRules, 2, undefined)); + await assert.rejects(harness.service.update(newRules, 2, 2)); assertDomainRestored(harness.snapshot(), before); } const preMutation = new Error('invalid config'); const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } }); - await assert.rejects(local.service.update(newRules, 2, undefined), (error) => error === preMutation); + await assert.rejects(local.service.update(newRules, 2, 2), (error) => error === preMutation); assert.equal(local.events.includes('runtime.restore'), false); const postMutation = new Error('remote failed'); const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } }); - await assert.rejects(remote.service.update(newRules, 2, undefined), (error) => error === postMutation); + await assert.rejects(remote.service.update(newRules, 2, 2), (error) => error === postMutation); assert.equal(remote.events.includes('runtime.restore'), true); }); @@ -207,7 +228,7 @@ test('route rules rollback continues and classifies runtime restore failure', as const aggregate = createHarness({ failures: { stateUpdates: [original], configRestore }, }); - await assert.rejects(aggregate.service.update(newRules, 2, undefined), (error) => { + await assert.rejects(aggregate.service.update(newRules, 2, 2), (error) => { assert.ok(error instanceof AggregateError); assert.deepEqual(error.errors, [original, configRestore]); return true; @@ -217,7 +238,7 @@ test('route rules rollback continues and classifies runtime restore failure', as const broken = createHarness({ failures: { stateUpdates: [original], runtimeRestore }, }); - await assert.rejects(broken.service.update(newRules, 2, undefined), (error) => { + await assert.rejects(broken.service.update(newRules, 2, 2), (error) => { assert.equal(error.code, 'PROCESS_START_FAILED'); assert.deepEqual(error.cause.errors, [original, runtimeRestore]); return true; @@ -229,13 +250,13 @@ test('route rules route preserves one adapter and state-only response', async () const calls = []; const route = createRouteRulesRoute({ routeRules: { update: async (...args) => { calls.push(args); } }, - readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, expectedRevision: 99 }), + readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, rulesContractVersion: 2 }), sendState: async () => { calls.push('sent'); }, }); const response = {}; assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false); - assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules' }, response), true); - assert.deepEqual(calls, [[newRules, 3, 99], 'sent']); + assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules/v2' }, response), true); + assert.deepEqual(calls, [[newRules, 3, 2], 'sent']); const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8'); assert.match(source, /createRouteRulesRoute\(\{/); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 9cb78fc..ecf7a29 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -21,12 +21,12 @@ const subscriptionConfig = { }], }; -test('client exposes one local proxy and routes local exceptions before the selected VPN', () => { +test('client exposes one local proxy and preserves mixed user-rule order before fallback', () => { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { routeRules: [ - { type: 'domain_suffix', value: 'ru', enabled: true }, - { type: 'domain', value: 'example.com', enabled: true }, - { type: 'domain_keyword', value: 'cdn', enabled: false }, + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + { type: 'domain_keyword', value: 'cdn', enabled: false, outbound: 'vpn' }, ], }); @@ -44,8 +44,8 @@ test('client exposes one local proxy and routes local exceptions before the sele timeout: '1s', }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, - { domain_suffix: ['ru'], outbound: 'direct' }, - { domain: ['example.com'], outbound: 'direct' }, + { domain: ['api.example.com'], outbound: 'test-vpn' }, + { domain_suffix: ['example.com'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); assert.equal(config.route.final, 'test-vpn'); @@ -55,7 +55,10 @@ test('client exposes one local proxy and routes local exceptions before the sele test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true, - routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }], + routeRules: [ + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + ], }); assert.deepEqual(config.route.rules, [ @@ -66,9 +69,9 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah timeout: '1s', }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, - { domain_suffix: ['ru'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'direct' }, ]); assert.equal(config.route.final, 'direct'); assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['test-vpn', 'direct']); + assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false); }); diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js index cc90b2a..ec74996 100644 --- a/test/server/singbox-gateway-mode.test.js +++ b/test/server/singbox-gateway-mode.test.js @@ -21,9 +21,13 @@ const subscriptionConfig = { }], }; -test('gateway routes .ru domains directly and other traffic through the selected VPN', () => { +test('gateway preserves mixed user-rule order and dynamic VPN target', () => { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { - routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }], + routeRules: [ + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + { type: 'domain_keyword', value: 'disabled', enabled: false, outbound: 'vpn' }, + ], }); assert.deepEqual(config.route.rule_set, []); @@ -47,7 +51,8 @@ test('gateway routes .ru domains directly and other traffic through the selected timeout: '1s', }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, - { domain_suffix: ['ru'], outbound: 'direct' }, + { domain: ['api.example.com'], outbound: 'test-vpn' }, + { domain_suffix: ['example.com'], outbound: 'direct' }, { inbound: ['tproxy-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); diff --git a/test/server/startup-recovery.test.js b/test/server/startup-recovery.test.js index fe26337..b3fe1d1 100644 --- a/test/server/startup-recovery.test.js +++ b/test/server/startup-recovery.test.js @@ -6,6 +6,9 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js'; +import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js'; + const root = path.resolve(import.meta.dirname, '../..'); function listen(server, ...args) { @@ -72,7 +75,7 @@ if (process.argv[2] === 'run') { function profileState(server) { return { - schemaVersion: 5, + schemaVersion: 6, revision: 4, profiles: [{ id: 'profile-a', @@ -116,6 +119,8 @@ async function startClientFixture(t, { state, cacheContents, config, + hostNetwork, + gatewayPresencePort, }) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-')); const { binDirectory, markerPath } = fakeSingbox(directory); @@ -126,6 +131,8 @@ async function startClientFixture(t, { if (config !== undefined) { fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config)); } + const hostNetworkPath = path.join(directory, 'host-network.json'); + if (hostNetwork !== undefined) fs.writeFileSync(hostNetworkPath, JSON.stringify(hostNetwork)); const port = await freePort(); const child = spawn(process.execPath, ['dist/server/main.js'], { cwd: root, @@ -136,7 +143,10 @@ async function startClientFixture(t, { DATA_DIR: directory, PORT: String(port), PATH: `${binDirectory}:${process.env.PATH || ''}`, - HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'), + HARBOR_HOST_NETWORK_STATE: hostNetwork === undefined + ? path.join(directory, 'missing-network.json') + : hostNetworkPath, + ...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}), HARBOR_TEST_RUN_MARKER: markerPath, }, stdio: ['ignore', 'ignore', 'pipe'], @@ -154,6 +164,82 @@ async function startClientFixture(t, { }; } +function bootStateWithRules() { + const normalized = normalizeSubscriptionConfig({ + outbounds: [{ + type: 'vless', + tag: 'Boot VPN', + server: 'boot.example', + server_port: 443, + uuid: '00000000-0000-4000-8000-000000000000', + }], + }); + const server = normalized.servers[0]; + const state = profileState(server); + state.profiles[0].subscriptionUrl = 'https://provider.example/0123456789abcdef0123456789abcdef'; + state.profiles[0].subscriptionConfig = normalized.config; + state.routeRules = [ + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + ]; + state.appliedRouteRules = []; + return state; +} + +test('local-vpn boot promotes the exact desired ordered rules to applied truth', async (t) => { + const state = bootStateWithRules(); + const fixture = await startClientFixture(t, { state }); + const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8')); + + assert.equal(fixture.state.route.mode, 'local-vpn'); + assert.deepEqual(fixture.state.route.activeLocalRules, state.routeRules); + assert.equal(fixture.state.route.localRulesPendingRestart, false); + assert.deepEqual(config.route.rules.slice(2, 4), [ + { domain: ['api.example.com'], outbound: state.profiles[0].servers[0].id }, + { domain_suffix: ['example.com'], outbound: 'direct' }, + ]); +}); + +test('gateway-direct boot keeps the local proxy and diagnostics but omits every user rule', async (t) => { + const state = bootStateWithRules(); + const gateway = http.createServer((req, res) => { + const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('nonce'); + const payload = buildGatewayPresence({ + appMode: 'gateway', + subscriptionUrl: state.profiles[0].subscriptionUrl, + gatewayId: 'gateway-test', + nonce, + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); + }); + const address = await listen(gateway, 0, '127.0.0.1'); + t.after(() => close(gateway)); + const fixture = await startClientFixture(t, { + state, + gatewayPresencePort: address.port, + hostNetwork: { + gateway: '127.0.0.1', + interface: 'lo0', + mac: 'aa:bb:cc:dd:ee:ff', + observedAt: new Date().toISOString(), + }, + }); + const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8')); + const stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8')); + + assert.equal(fixture.state.route.mode, 'gateway-direct'); + assert.deepEqual(fixture.state.route.activeLocalRules, []); + assert.equal(fixture.state.route.localRulesPendingRestart, false); + assert.deepEqual(stored.appliedRouteRules, []); + assert.deepEqual(config.inbounds.map(({ tag }) => tag), ['mixed-in', 'diagnostics-vpn-in']); + assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false); + assert.deepEqual(config.route.rules[1], { + inbound: ['diagnostics-vpn-in'], + outbound: state.profiles[0].servers[0].id, + }); +}); + test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => { const staleServer = { id: 'stale-server', @@ -298,7 +384,7 @@ test('stopped Gateway boot explicitly stops an already running remote dataplane' }); await listen(dataplane, socketPath); fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({ - schemaVersion: 5, + schemaVersion: 6, revision: 3, profiles: [], desiredProfileId: '', diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 4fdade2..825a00b 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -90,6 +90,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot', appliedServerSnapshot: snapshot.profiles[0].servers[0], }); assert.equal(snapshot.connection.process, 'running'); + assert.equal(snapshot.route.rulesContractVersion, 2); assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false); assert.throws( () => assertStateSnapshot({ ...snapshot, revision: -1 }), @@ -107,7 +108,7 @@ test('startup discards a rejected cached subscription and returns to first-run', server: '0.0.0.0', server_port: 1, }; - const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }]; + const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' }]; fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({ subscriptionUrl, selectedTag: rejectedServer.tag, @@ -296,14 +297,15 @@ setInterval(() => {}, 60_000); assert.equal(initial.selection.appliedProfileId, 'profile_primary'); assert.equal(initial.selection.appliedServerSnapshot.id, testServerId); assert.deepEqual(initial.route.localRules, [ - { type: 'domain_suffix', value: 'ru', enabled: true }, + { type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }, ]); + assert.equal(initial.route.rulesContractVersion, 2); assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules); assert.equal(initial.route.localRulesRevision, 0); assert.equal(initial.route.localRulesPendingRestart, false); assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false); const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); - assert.equal(migratedState.schemaVersion, 5); + assert.equal(migratedState.schemaVersion, 6); assert.equal(migratedState.profiles.length, 1); assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl); assert.equal(migratedState.profiles[0].desiredServerId, testServerId); @@ -521,19 +523,20 @@ 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'); - let routed = await mutation('/api/route-rules', 'PUT', { + let routed = await mutation('/api/route-rules/v2', 'PUT', { expectedRulesRevision: rulesRevision, + rulesContractVersion: 2, rules: [ - { type: 'domain_suffix', value: 'ru', enabled: false }, - { type: 'domain', value: 'https://Example.com/private?q=1', enabled: true }, - { type: 'domain_suffix', value: '*.Example.org', enabled: true }, + { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' }, + { type: 'domain', value: 'https://Example.com/private?q=1', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' }, ], }); rulesRevision = routed.state.route.localRulesRevision; assert.deepEqual(routed.state.route.localRules, [ - { type: 'domain_suffix', value: 'ru', enabled: false }, - { type: 'domain', value: 'example.com', enabled: true }, - { type: 'domain_suffix', value: 'example.org', enabled: true }, + { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' }, + { type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' }, ]); assert.equal(routed.state.route.localRulesPendingRestart, false); assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules); @@ -545,16 +548,17 @@ setInterval(() => {}, 60_000); timeout: '1s', }, { inbound: ['diagnostics-vpn-in'], outbound: testServerId }, - { domain: ['example.com'], outbound: 'direct' }, + { domain: ['example.com'], outbound: testServerId }, { domain_suffix: ['example.org'], outbound: 'direct' }, ]); await mutation('/api/singbox/stop'); - routed = await mutation('/api/route-rules', 'PUT', { + routed = await mutation('/api/route-rules/v2', 'PUT', { expectedRulesRevision: rulesRevision, + rulesContractVersion: 2, rules: [ ...routed.state.route.localRules, - { type: 'domain_keyword', value: 'media', enabled: true }, + { type: 'domain_keyword', value: 'media', enabled: true, outbound: 'vpn' }, ], }); rulesRevision = routed.state.route.localRulesRevision; @@ -565,27 +569,48 @@ setInterval(() => {}, 60_000); assert.equal(restartedRules.state.route.localRulesPendingRestart, false); assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules); - const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', { + const invalidRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', { expectedRulesRevision: rulesRevision, - rules: [{ type: 'domain_regex', value: '.*' }], + rulesContractVersion: 2, + rules: [{ type: 'domain_regex', value: '.*', enabled: true, outbound: 'direct' }], }); 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', { + const staleRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', { expectedRulesRevision: 0, + rulesContractVersion: 2, rules: [], }); assert.equal(staleRules.response.status, 409); assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT'); assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules); + const skewConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'); const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', { expectedRevision: revision, rules: routed.state.route.localRules, }); - assert.equal(legacyNoop.response.status, 200); + assert.equal(legacyNoop.response.status, 400); + assert.equal(legacyNoop.payload.error.code, 'REQUEST_INVALID'); + assert.equal((await request(port, '/api/state')).revision, revision); + assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig); + + for (const rules of [ + [{ type: 'domain', value: 'missing.example', enabled: true }], + [{ type: 'domain', value: 'unknown.example', enabled: true, outbound: 'block' }], + ]) { + const rejected = await rawRequest(port, '/api/route-rules/v2', 'PUT', { + expectedRulesRevision: rulesRevision, + rulesContractVersion: 2, + rules, + }); + assert.equal(rejected.response.status, 400); + assert.equal(rejected.payload.error.code, 'REQUEST_INVALID'); + assert.equal((await request(port, '/api/state')).revision, revision); + assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig); + } const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'); fs.writeFileSync(singboxPath, `#!/usr/bin/env node @@ -599,9 +624,10 @@ process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 60_000); `); fs.chmodSync(singboxPath, 0o755); - const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', { + const failedRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', { expectedRulesRevision: rulesRevision, - rules: [{ type: 'domain', value: 'broken.example' }], + rulesContractVersion: 2, + rules: [{ type: 'domain', value: 'broken.example', enabled: true, outbound: 'vpn' }], }); assert.equal(failedRules.response.status, 422); assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID'); diff --git a/test/server/state-store.test.js b/test/server/state-store.test.js index 6516a54..cf6429d 100644 --- a/test/server/state-store.test.js +++ b/test/server/state-store.test.js @@ -61,7 +61,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION); assert.deepEqual(migrated.routeRules, [ - { type: 'domain_suffix', value: 'ru', enabled: true }, + { type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }, ]); const primary = migrated.profiles[0]; assert.equal(primary.label, 'Основной'); @@ -74,6 +74,61 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION); }); +test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t) => { + const filePath = fixture(t); + const legacy = { + schemaVersion: 5, + revision: 11, + routeRulesRevision: 7, + routeRules: [ + { type: 'domain', value: 'api.example.com', enabled: true }, + { type: 'domain_suffix', value: 'example.com', enabled: false }, + ], + appliedRouteRules: [ + { type: 'domain_suffix', value: 'example.com', enabled: false }, + { type: 'domain', value: 'api.example.com', enabled: true }, + ], + }; + const bytes = JSON.stringify(legacy); + fs.writeFileSync(filePath, bytes); + + const store = createStateStore(filePath, { + now: () => new Date('2026-08-17T12:00:00.000Z'), + }); + const migrated = store.read(); + + assert.equal(migrated.schemaVersion, 6); + assert.equal(migrated.routeRulesRevision, 7); + assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']); + assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [ + ['domain_suffix', 'direct'], + ['domain', 'direct'], + ]); + assert.equal(store.migration.fromVersion, 5); + assert.equal(store.migration.toVersion, 6); + assert.match(store.migration.backupPath, /\.backup-v5-/); + assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes); +}); + +test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => { + for (const [name, rule] of [ + ['missing', { type: 'domain', value: 'example.com', enabled: true }], + ['unknown', { type: 'domain', value: 'example.com', enabled: true, outbound: 'block' }], + ]) { + const filePath = `${fixture(t)}-${name}`; + const bytes = JSON.stringify({ + schemaVersion: 6, + routeRules: [rule], + appliedRouteRules: [], + }); + fs.writeFileSync(filePath, bytes); + const store = createStateStore(filePath); + assert.throws(() => store.read(), /outbound/); + assert.equal(fs.readFileSync(filePath, 'utf8'), bytes); + assert.equal(store.migration, null); + } +}); + test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => { const filePath = fixture(t); fs.writeFileSync(filePath, JSON.stringify({ diff --git a/test/server/subscription-mutation.test.js b/test/server/subscription-mutation.test.js index df06f6f..20c0633 100644 --- a/test/server/subscription-mutation.test.js +++ b/test/server/subscription-mutation.test.js @@ -7,7 +7,7 @@ import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/s const oldServer = { id: 'shared', label: 'Old', host: 'old.example', port: 443, protocol: 'vless' }; const nextServer = { id: 'next', label: 'Next', host: 'next.example', port: 443, protocol: 'vless' }; -const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }]; +const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }]; const profile = (id, label, server = oldServer) => ({ id, @@ -47,7 +47,7 @@ function defaultState() { function createHarness(overrides = {}) { let state = structuredClone(overrides.state ?? defaultState()); let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config'; - let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' }; + let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'local-vpn', gatewayId: '' }); let running = overrides.running ?? true; let tail = Promise.resolve(); let updateCount = 0; @@ -267,6 +267,25 @@ test('active refresh applies a retained server and keeps last-applied snapshot i assert.equal(after.running, true); }); +test('active refresh publishes the route rules used by the rebuilt config', async () => { + const nextRules = [{ type: 'domain', value: 'vpn.example', enabled: true, outbound: 'vpn' }]; + const state = defaultState(); + state.routeRules = nextRules; + state.appliedRouteRules = routeRules; + const local = createHarness({ state, fetchSubscription: async () => parsed('personal', [oldServer]) }); + await local.service.refreshProfile('personal', state.revision); + assert.deepEqual(local.snapshot().state.appliedRouteRules, nextRules); + assert.deepEqual(JSON.parse(local.snapshot().config).rules, nextRules); + + const bypassed = createHarness({ + state, + gatewayAuto: { mode: 'gateway-direct', gatewayId: 'gateway' }, + fetchSubscription: async () => parsed('personal', [oldServer]), + }); + await bypassed.service.refreshProfile('personal', state.revision); + assert.deepEqual(bypassed.snapshot().state.appliedRouteRules, []); +}); + test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => { const inactive = createHarness(); await inactive.service.deleteProfile('work', 'delete', 3); @@ -294,7 +313,7 @@ test('deleting a pending desired profile leaves the running applied route untouc const after = harness.snapshot(); assert.equal(after.config, 'old-config'); assert.equal(after.running, true); - assert.deepEqual(after.gatewayAuto, { mode: 'gateway-direct', gatewayId: 'gateway' }); + assert.deepEqual(after.gatewayAuto, { mode: 'local-vpn', gatewayId: '' }); assert.equal(harness.calls.includes('gateway.set'), false); }); diff --git a/test/shared/routing-rules.test.js b/test/shared/routing-rules.test.js index cc8b175..3371f2c 100644 --- a/test/shared/routing-rules.test.js +++ b/test/shared/routing-rules.test.js @@ -5,16 +5,16 @@ import { canAppendRouteRule, normalizeRouteRules } from '../../dist/shared/routi 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', enabled: false }, + { type: 'domain', value: 'https://Example.com/news?id=1', outbound: 'vpn' }, + { type: 'domain_suffix', value: '*.Example.org', outbound: 'direct' }, + { type: 'domain_keyword', value: ' CDN ', outbound: 'vpn' }, + { type: 'domain', value: 'example.com', outbound: 'direct' }, + { type: 'domain_suffix', value: '.ru', enabled: false, outbound: 'direct' }, ], { strict: true }), [ - { type: 'domain', value: 'example.com', enabled: true }, - { type: 'domain_suffix', value: 'example.org', enabled: true }, - { type: 'domain_keyword', value: 'cdn', enabled: true }, - { type: 'domain_suffix', value: 'ru', enabled: false }, + { type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' }, + { type: 'domain_keyword', value: 'cdn', enabled: true, outbound: 'vpn' }, + { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' }, ]); assert.equal(canAppendRouteRule([{ value: 'filled' }]), true); assert.equal(canAppendRouteRule([{ value: '' }]), false); @@ -22,11 +22,30 @@ test('local route rules normalize URLs, suffixes and duplicates', () => { test('invalid local route rules fail at the strict boundary', () => { assert.throws( - () => normalizeRouteRules([{ type: 'domain_regex', value: '.*' }], { strict: true }), + () => normalizeRouteRules([{ type: 'domain_regex', value: '.*', outbound: 'direct' }], { strict: true }), /Invalid domain rule type/, ); assert.throws( - () => normalizeRouteRules([{ type: 'domain_keyword', value: 'bad/path' }], { strict: true }), + () => normalizeRouteRules([{ type: 'domain_keyword', value: 'bad/path', outbound: 'direct' }], { strict: true }), /Invalid domain rule value/, ); + assert.throws( + () => normalizeRouteRules([{ type: 'domain', value: 'example.com' }], { strict: true }), + /outbound is required/, + ); + assert.throws( + () => normalizeRouteRules([{ type: 'domain', value: 'example.com', outbound: 'block' }], { strict: true }), + /Invalid route rule outbound/, + ); +}); + +test('legacy rules default to direct while order and first duplicate stay canonical', () => { + assert.deepEqual(normalizeRouteRules([ + { type: 'domain_suffix', value: 'example.com' }, + { type: 'domain', value: 'api.example.com', outbound: 'vpn' }, + { type: 'domain_suffix', value: 'example.com', outbound: 'vpn' }, + ]), [ + { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, + { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, + ]); }); diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js index c611251..8f4fbac 100644 --- a/test/web/api-errors.test.js +++ b/test/web/api-errors.test.js @@ -93,11 +93,12 @@ test('typed endpoint facade preserves exact request contracts and raw payload id [() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', { method: 'POST', body: JSON.stringify({ enabled: true }), }], - [() => api.routeRules.update([{ type: 'domain', value: 'example.com' }], 7), '/api/route-rules', { + [() => api.routeRules.update([{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }], 7), '/api/route-rules/v2', { method: 'PUT', body: JSON.stringify({ - rules: [{ type: 'domain', value: 'example.com' }], + rules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }], expectedRulesRevision: 7, + rulesContractVersion: 2, }), }], [() => api.devices.list(), '/api/devices', {}], diff --git a/test/web/harbor-state.test.js b/test/web/harbor-state.test.js index 89422ae..94c62de 100644 --- a/test/web/harbor-state.test.js +++ b/test/web/harbor-state.test.js @@ -67,6 +67,30 @@ test('typed Harbor client validates unknown state and isolates wire compatibilit assert.equal(failed.transport.bootStatus, 'incompatible-api'); }); +test('an old rules snapshot remains readable but keeps the mutation capability absent', () => { + const current = createStateSnapshot({ + storedState: { + routeRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }], + appliedRouteRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }], + }, + runtime: { running: true }, + gatewayAuto: null, + appMode: 'client', + configExists: true, + now: new Date('2026-08-17T12:00:00.000Z'), + }); + const legacy = structuredClone(current); + delete legacy.route.rulesContractVersion; + for (const rule of [...legacy.route.localRules, ...legacy.route.activeLocalRules]) delete rule.outbound; + + const parsed = parseHarborState(legacy); + assert.equal(parsed.route.rulesContractVersion, undefined); + assert.deepEqual(parsed.route.localRules, [ + { type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }, + ]); + assert.equal(parseHarborState(current).route.rulesContractVersion, 2); +}); + test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => { let state = receive(initialHarborState, snapshot(1, 'one')); const poll = deferred(); diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index 5eca4d7..2b49567 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -132,7 +132,8 @@ test('secondary menus share one right rail and switch equal drawers as a vertica assert.match(component, / { + assert.match(rule('.client-local-rule'), /grid-template-columns:\s*44px 24px 116px minmax\(0, 1fr\) 28px/); + assert.match(rule('.client-local-rule-meta'), /grid-column:\s*2 \/ -1[\s\S]*grid-row:\s*2/); + const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\s*$/.exec(layoutStyles)?.[1] || ''; + assert.match(mobile, /grid-template-columns:\s*44px 44px minmax\(0, 1fr\) 44px/); + assert.match(mobile, /\.client-local-rule input \{[\s\S]*grid-row:\s*2/); + assert.match(mobile, /\.client-local-rule-meta \{[\s\S]*grid-row:\s*3/); + assert.match(mobile, /\.client-rule-handle,[\s\S]*width:\s*44px;[\s\S]*height:\s*44px/); + assert.match(mobile, /\.client-rule-type-trigger,[\s\S]*\.client-rule-outbound button \{[\s\S]*height:\s*44px/); + assert.match(mobile, /\.client-rule-type-list button \{[\s\S]*min-height:\s*44px/); + const compact = layoutStyles.slice(layoutStyles.indexOf('@media (max-width: 360px)')); + assert.match(compact, /\.client-local-rule-meta \{[\s\S]*flex-wrap:\s*wrap/); + assert.match(compact, /\.client-rule-outbound \{[\s\S]*width:\s*100%[\s\S]*flex-basis:\s*100%/); + assert.match(compact, /\.client-local-rule-status \{[\s\S]*width:\s*100%[\s\S]*white-space:\s*normal/); +}); + test('connectivity diagnostics render stable compact tables before the first run', () => { assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/); assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/); diff --git a/test/web/routing-feature-contract.test.js b/test/web/routing-feature-contract.test.js index efdf87e..59a13da 100644 --- a/test/web/routing-feature-contract.test.js +++ b/test/web/routing-feature-contract.test.js @@ -21,16 +21,33 @@ test('routing feature is the sole owner at the four existing composition positio test('routing controller preserves snapshot drafts, live status and guarded close/save semantics', () => { assert.match(feature, /const savedRules = route\?\.localRules \|\| \[\]/); - assert.match(feature, /baselineRef\.current = JSON\.stringify\(savedRules\.map/); - assert.match(feature, /setRules\(savedRules\.map\(createLocalRuleDraft\)\)/); + assert.match(feature, /baselineRef\.current = localRulesSignature\(savedRules\)/); + assert.match(feature, /const nextRules = savedRules\.map\(createLocalRuleDraft\)/); assert.match(feature, /setRevision\(route\?\.localRulesRevision \|\| 0\)/); - assert.match(feature, /if \(dirty\) \{[\s\S]*setConfirmingClose\(true\);[\s\S]*return false/); + assert.match(feature, /const currentRules = cancelReorder\(false\)[\s\S]*localRulesSignature\(currentRules\) !== baselineRef\.current/); assert.match(feature, /const result = routingSaveState\(await onSave\(values, revision\)\)/); - assert.match(feature, /if \(!result\) return;[\s\S]*baselineRef\.current = JSON\.stringify\(values\);[\s\S]*setRevision\(result\.localRulesRevision\)/); + assert.match(feature, /if \(!result\) return;[\s\S]*baselineRef\.current = localRulesSignature\(values\);[\s\S]*setRevision\(result\.localRulesRevision\)/); assert.match(feature, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/); assert.match(feature, /Number\.isSafeInteger\(localRulesRevision\)[\s\S]*localRulesRevision as number\) < 0[\s\S]*typeof localRulesPendingRestart !== 'boolean'/); }); +test('routing controller owns ordered outbound drafts, capability gating and drag cancellation', () => { + assert.match(feature, /map\(\(\{ type, value, enabled, outbound \}\) => \(\{ type, value, enabled, outbound \}\)\)/); + assert.match(feature, /route\?\.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION/); + assert.match(feature, /if \(!editable \|\| blocked\) return/); + assert.match(feature, /sameRule\(rule, savedRules\[index\]\)/); + assert.match(feature, /sameRule\(rule, activeRules\[index\]\)/); + assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'pointer'\)[\s\S]*setPointerCapture/); + assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'keyboard'\)/); + assert.match(feature, /event\.detail === 0\) toggleKeyboardReorder/); + assert.match(feature, /event\.key === 'Tab'[\s\S]*finishReorder\('focus-leave'\)/); + assert.match(feature, /onBlur=\{\(event\) => feature\.handleReorderBlur/); + assert.match(feature, /endRuleReorder\(session, 'unmount'\)\.stopAutoScroll/); + assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/); + assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/); + assert.match(feature, /stopAutoScroll\(session\)[\s\S]*Перемещение отменено/); +}); + test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => { assert.match(feature, /keyboardEvent\.key !== 'Escape' \|\| keyboardEvent\.defaultPrevented/); assert.match(feature, /addEventListener\('beforeunload', warnBeforeUnload\)/); diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js index 264f027..4e7588b 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -27,7 +27,7 @@ test('rule editor add latency stays constant and dirty exits are guarded', () => assert.match(routing, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/); assert.match(routing, /Ждёт перезапуска/); assert.match(routing, /Перезапустить VPN/); - assert.match(routing, /const pendingRestart = connected && route\?\.localRulesPendingRestart === true/); + assert.match(routing, /const pendingRestart = connected && !bypassed && route\?\.localRulesPendingRestart === true/); assert.match(routing, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/); assert.match(routing, /client-deletable-row/); assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/); @@ -89,12 +89,27 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic assert.doesNotMatch(component, /ГОТОВО/); assert.doesNotMatch(component, />Error<|>Copied { + assert.match(routing, /className="client-rule-handle"[\s\S]*type="button"[\s\S]*aria-label=\{`Переместить правило, позиция/); + assert.match(routing, /aria-describedby="client-rule-reorder-instructions"[\s\S]*aria-pressed=\{lifted\}/); + assert.match(routing, /onPointerDown=\{\(event\) => feature\.startPointerReorder\(event, rule\._key\)\}/); + assert.doesNotMatch(routing, /data-rule-key[^>]*onPointerDown/); + assert.match(routing, /onClick=\{\(event\) => feature\.handleReorderClick\(event, rule\._key\)\}/); + assert.match(routing, /