Update Harbor client and gateway functionality
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-17 15:23:16 +03:00
parent 0b39211fbd
commit 7c255192b0
41 changed files with 1443 additions and 218 deletions
+5 -3
View File
@@ -133,13 +133,15 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси. Допустимы порты от `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 и подключаемые списки пока не поддерживаются. Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе. При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
## Системный прокси macOS ## Системный прокси macOS
+12 -2
View File
@@ -1,6 +1,6 @@
# Harbor application state v1 # 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: 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. 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 ## 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 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. 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<fromVersion>-*` 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.
+12 -2
View File
@@ -1,6 +1,6 @@
# Harbor state recovery # 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 ## 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. 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 ## Corrupt JSON
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: 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. 3. Restore only matching state/cache backups to their original filenames.
4. Start Harbor and verify `GET /api/state` before applying a profile. 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<fromVersion>-*` 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.
@@ -237,7 +237,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
appliedServerId: server.id, appliedServerId: server.id,
appliedServerSnapshot: server, appliedServerSnapshot: server,
connectionDesired: 'running', connectionDesired: 'running',
appliedRouteRules: state.routeRules, appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules,
})); }));
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishRollback(error, [
@@ -205,7 +205,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
current = candidate; current = candidate;
gatewayAutoPublished = true; gatewayAutoPublished = true;
stateCommitStarted = 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) { if (persistEnabled !== undefined) {
stateCommitStarted = true; stateCommitStarted = true;
@@ -7,7 +7,10 @@ import {
type StoredState, type StoredState,
} from '../../../shared/contracts/state.js'; } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.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 type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js'; import { finishRollback } from '../../services/rollback.js';
@@ -29,6 +32,7 @@ interface RouteRulesDependencies {
applyCommand(): Promise<RuntimeCommandResult>; applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>; restoreRunning(): Promise<unknown>;
}; };
route?: { isGatewayDirect(): boolean };
serialize<T>(operation: () => Promise<T>): Promise<T>; serialize<T>(operation: () => Promise<T>): Promise<T>;
runOperation<T>(operation: () => Promise<T>): Promise<T>; runOperation<T>(operation: () => Promise<T>): Promise<T>;
} }
@@ -36,6 +40,7 @@ interface RouteRulesDependencies {
export function createRouteRulesService(dependencies: RouteRulesDependencies) { export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => { const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
const wasRunning = await dependencies.runtime.isRunning(); const wasRunning = await dependencies.runtime.isRunning();
const bypassed = dependencies.route?.isGatewayDirect() === true;
const targetProfile = wasRunning const targetProfile = wasRunning
? appliedProfile(previousState) ? appliedProfile(previousState)
: desiredProfile(previousState); : desiredProfile(previousState);
@@ -45,13 +50,14 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const subscriptionConfig = targetProfile const subscriptionConfig = targetProfile
? dependencies.subscription.readConfig(targetProfile.id) ? dependencies.subscription.readConfig(targetProfile.id)
: null; : null;
if (!targetServerId || !subscriptionConfig) { if (bypassed || !targetServerId || !subscriptionConfig) {
let stateCommitStarted = false; let stateCommitStarted = false;
try { try {
stateCommitStarted = true; stateCommitStarted = true;
dependencies.state.update((state) => ({ dependencies.state.update((state) => ({
...state, ...state,
routeRules, routeRules,
...(bypassed ? { appliedRouteRules: [] } : {}),
routeRulesRevision: state.routeRulesRevision + 1, routeRulesRevision: state.routeRulesRevision + 1,
})); }));
} catch (error) { } catch (error) {
@@ -68,14 +74,17 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
routeRules, routeRules,
); );
const previousConfig = dependencies.config.read(); const previousConfig = dependencies.config.read();
const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
let configMutationStarted = false; let configMutationStarted = false;
let runtimeMutationStarted = false; let runtimeMutationStarted = false;
let stateCommitStarted = false; let stateCommitStarted = false;
try { try {
configMutationStarted = true; if (configChanged) {
dependencies.config.write(candidateConfig); configMutationStarted = true;
if (wasRunning) { dependencies.config.write(candidateConfig);
}
if (wasRunning && configChanged) {
const command = await dependencies.runtime.applyCommand(); const command = await dependencies.runtime.applyCommand();
runtimeMutationStarted = command.mutationStarted; runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error; 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[]; let routeRules: RouteRule[];
try { try {
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[]; routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
} catch (cause) { } catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause }); throw new HarborError('REQUEST_INVALID', { cause });
} }
const rulesRevision = expectedRulesRevision ?? expectedRevision; if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
throw new HarborError('REQUEST_INVALID'); throw new HarborError('REQUEST_INVALID');
} }
return dependencies.serialize(async () => { return dependencies.serialize(async () => {
const current = dependencies.state.read(); const current = dependencies.state.read();
const currentRevision = expectedRulesRevision == null if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
? current.revision
: current.routeRulesRevision;
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return; if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await dependencies.runOperation(() => applyRules(current, routeRules)); await dependencies.runOperation(() => applyRules(current, routeRules));
}); });
@@ -297,6 +297,9 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
profiles: replaceProfile(current, refreshedProfile), profiles: replaceProfile(current, refreshedProfile),
appliedServerId: nextAppliedServerId, appliedServerId: nextAppliedServerId,
appliedServerSnapshot: nextAppliedServer, appliedServerSnapshot: nextAppliedServer,
appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct'
? []
: current.routeRules,
})); }));
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishRollback(error, [
+3 -3
View File
@@ -11,9 +11,9 @@ interface RouteRulesRouteDependencies {
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) { export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false; if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false;
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req); const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req);
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision); await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion);
await dependencies.sendState(res); await dependencies.sendState(res);
return true; return true;
}, },
+8 -1
View File
@@ -538,6 +538,10 @@ const routeRulesService = createRouteRulesService({
), ),
restoreRunning: () => startSingbox(), restoreRunning: () => startSingbox(),
}, },
route: {
isGatewayDirect: () => settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct',
},
serialize: serializeControl, serialize: serializeControl,
runOperation: (operation) => withOperation('route-rules', operation), runOperation: (operation) => withOperation('route-rules', operation),
}); });
@@ -851,7 +855,10 @@ if (bootWantsRunning) {
appliedProfileId: appliedProfile.id, appliedProfileId: appliedProfile.id,
appliedServerId: appliedServer.id, appliedServerId: appliedServer.id,
appliedServerSnapshot: appliedServer, appliedServerSnapshot: appliedServer,
...(target ? { appliedRouteRules: state.routeRules } : {}), ...(settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct'
? { appliedRouteRules: [] }
: target ? { appliedRouteRules: state.routeRules } : {}),
})); }));
} }
}) })
+6 -3
View File
@@ -5,7 +5,7 @@ import {
normalizeStoredState, normalizeStoredState,
type PersistedState, type PersistedState,
} from '../../shared/contracts/state.js'; } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; import { INITIAL_ROUTE_RULES, normalizeRouteRules } from '../../shared/routingRules.js';
import { import {
normalizeServers, normalizeServers,
resolveServerId, resolveServerId,
@@ -13,7 +13,7 @@ import {
type NormalizedServer, type NormalizedServer,
} from '../../shared/serverIdentity.js'; } from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 5; export const STATE_SCHEMA_VERSION = 6;
export interface AtomicWriteOptions { export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void; beforeRename?: (temporaryPath: string, filePath: string) => void;
@@ -143,6 +143,8 @@ export function migrateStoredState(
const routeRules = version < 3 const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? 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 legacyCache = record(legacySubscriptionCache);
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim(); const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
const cachedSubscriptionUrl = String(legacyCache.url || '').trim(); const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
@@ -168,7 +170,8 @@ export function migrateStoredState(
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped'); const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
const normalized = normalizeStoredState({ const normalized = normalizeStoredState({
...stored, ...stored,
routeRules, routeRules: migratedRouteRules,
appliedRouteRules: migratedAppliedRouteRules,
...(version < 5 && !Array.isArray(stored.profiles) ? { ...(version < 5 && !Array.isArray(stored.profiles) ? {
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''), subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null, subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
+7 -4
View File
@@ -70,9 +70,12 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
set_system_proxy: false, set_system_proxy: false,
}, },
]; ];
const directRules = normalizeRouteRules(routeRules) const userRules = (directClient ? [] : normalizeRouteRules(routeRules))
.filter((rule) => rule.enabled) .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 const rules = clientMode
? [ ? [
{ {
@@ -82,7 +85,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT, timeout: SNIFF_TIMEOUT,
}, },
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag }, { inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
...directRules, ...userRules,
{ inbound: [MIXED_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag },
] ]
: [ : [
@@ -93,7 +96,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT, timeout: SNIFF_TIMEOUT,
}, },
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules, ...userRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag },
]; ];
+34 -5
View File
@@ -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'; import { normalizeServers, resolveServerId } from '../serverIdentity.js';
export type HarborMode = 'client' | 'gateway'; export type HarborMode = 'client' | 'gateway';
@@ -18,6 +22,7 @@ export interface RouteRule {
type: 'domain' | 'domain_suffix' | 'domain_keyword'; type: 'domain' | 'domain_suffix' | 'domain_keyword';
value: string; value: string;
enabled: boolean; enabled: boolean;
outbound: RouteRuleOutbound;
} }
export interface StoredProfile { export interface StoredProfile {
@@ -74,6 +79,7 @@ export interface StateSnapshot {
lastError: string | null; lastError: string | null;
}; };
route: { route: {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode: string; mode: string;
gatewayAddress: string | null; gatewayAddress: string | null;
gatewayUiOrigin: string | null; gatewayUiOrigin: string | null;
@@ -343,7 +349,8 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct' : routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found' ? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local'; : 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; const appliedServerSnapshot = stored.appliedServerSnapshot;
return assertStateSnapshot({ return assertStateSnapshot({
@@ -377,6 +384,7 @@ export function createStateSnapshot({
lastError: null, lastError: null,
}, },
route: { route: {
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
mode: routeMode, mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null, gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
@@ -387,7 +395,9 @@ export function createStateSnapshot({
localRules: stored.routeRules, localRules: stored.routeRules,
activeLocalRules, activeLocalRules,
localRulesRevision: stored.routeRulesRevision, localRulesRevision: stored.routeRulesRevision,
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules), localRulesPendingRestart: localRulesBypassed
? false
: !isSameRules(stored.routeRules, activeLocalRules),
}, },
operation: { operation: {
kind: nullableText(operation.kind), kind: nullableText(operation.kind),
@@ -402,7 +412,24 @@ export function createStateSnapshot({
} }
export function assertStateSnapshot(snapshot: unknown): StateSnapshot { 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 validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value: unknown) => value === null || validDate(value); const nullableDate = (value: unknown) => value === null || validDate(value);
const nullableString = (value: unknown) => value === null || typeof value === 'string'; 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) && ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' && typeof rule.value === 'string' &&
Boolean(rule.value) && Boolean(rule.value) &&
typeof rule.enabled === 'boolean' typeof rule.enabled === 'boolean' &&
['vpn', 'direct'].includes(rule.outbound)
); );
const validProfile = (profile: ProfileSnapshot) => ( const validProfile = (profile: ProfileSnapshot) => (
profile && profile &&
@@ -469,6 +497,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!nullableDate(candidate.connection.startedAt) || !nullableDate(candidate.connection.startedAt) ||
!nullableString(candidate.connection.lastError) || !nullableString(candidate.connection.lastError) ||
!candidate.route || !candidate.route ||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
typeof candidate.route.mode !== 'string' || typeof candidate.route.mode !== 'string' ||
!nullableString(candidate.route.gatewayAddress) || !nullableString(candidate.route.gatewayAddress) ||
!nullableString(candidate.route.gatewayUiOrigin) || !nullableString(candidate.route.gatewayUiOrigin) ||
+20 -4
View File
@@ -1,16 +1,20 @@
export const INITIAL_ROUTE_RULES = Object.freeze([ 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_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 const MAX_ROUTE_RULES = 200;
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword'; export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
export type RouteRuleOutbound = 'vpn' | 'direct';
export interface NormalizedRouteRule { export interface NormalizedRouteRule {
type: RouteRuleType; type: RouteRuleType;
value: string; value: string;
enabled: boolean; enabled: boolean;
outbound: RouteRuleOutbound;
} }
function record(value: unknown): Record<string, unknown> { function record(value: unknown): Record<string, unknown> {
@@ -28,20 +32,32 @@ function hostname(value: unknown) {
return normalized; return normalized;
} }
function normalizeRule(input: unknown): NormalizedRouteRule { function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
const rule = record(input); const rule = record(input);
const type = String(rule.type || '').trim(); const type = String(rule.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type'); if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') { if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state'); 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' const value = type === 'domain_keyword'
? String(rule.value || '').trim().toLowerCase() ? String(rule.value || '').trim().toLowerCase()
: hostname(rule.value); : hostname(rule.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) { if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule 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( export function normalizeRouteRules(
@@ -60,7 +76,7 @@ export function normalizeRouteRules(
const normalized: NormalizedRouteRule[] = []; const normalized: NormalizedRouteRule[] = [];
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try { try {
const rule = normalizeRule(candidate); const rule = normalizeRule(candidate, strict);
const key = `${rule.type}:${rule.value}`; const key = `${rule.type}:${rule.value}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.16', macClient: '0.26.0',
gatewayClient: '0.26.15', gatewayClient: '0.27.0',
gatewayBackend: '0.26.5', gatewayBackend: '0.27.0',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+3 -2
View File
@@ -1,5 +1,6 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js'; import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js'; import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
type RequestOptions = Omit<RequestInit, 'headers'> & { type RequestOptions = Omit<RequestInit, 'headers'> & {
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -148,9 +149,9 @@ export const api = {
}), }),
}, },
routeRules: { routeRules: {
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', { update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules/v2', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }), body: JSON.stringify({ rules, expectedRulesRevision, rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION }),
}), }),
}, },
devices: { devices: {
+503 -46
View File
@@ -2,16 +2,37 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
type FocusEvent as ReactFocusEvent,
type FormEvent, type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom'; 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 type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js'; import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { Drawer } from '../../ui/Drawer.js'; import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.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]> = [ const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'], ['domain', 'Точный домен'],
@@ -31,6 +52,8 @@ interface DraftRule extends RouteRule {
} }
interface RoutingState { interface RoutingState {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode?: string;
localRules?: RouteRule[]; localRules?: RouteRule[];
activeLocalRules?: RouteRule[]; activeLocalRules?: RouteRule[];
localRulesRevision?: number; localRulesRevision?: number;
@@ -50,39 +73,57 @@ interface RoutingSaveState {
localRulesPendingRestart: boolean; 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; let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({ const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule, ...rule,
enabled: rule?.enabled !== false, enabled: rule?.enabled !== false,
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
_key: `route-rule-${localRuleDraftId += 1}`, _key: `route-rule-${localRuleDraftId += 1}`,
}); });
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing) .filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })); .map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify( const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules rules
.filter((rule) => !rule.removing) .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) => ( const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}` `${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}`
);
const sameRule = (left?: RouteRule, right?: RouteRule) => (
Boolean(left && right && localRuleKey(left) === localRuleKey(right))
); );
function localRuleStatus( function localRuleStatus(
rule: DraftRule, rule: DraftRule,
index: number,
savedRules: RouteRule[], savedRules: RouteRule[],
activeRules: RouteRule[], activeRules: RouteRule[],
runtimeActive: boolean, runtimeActive: boolean,
bypassed: boolean,
) { ) {
const key = localRuleKey(rule); if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено']; if (!rule.enabled) return ['disabled', 'Выключено'];
if (bypassed) return ['bypassed', 'Обходится'];
if (!runtimeActive) return ['saved', 'Сохранено']; if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно']; if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска']; return ['pending', 'Ждёт перезапуска'];
} }
@@ -121,19 +162,34 @@ export function useRoutingFeature({
const [rules, setRules] = useState<DraftRule[]>([]); const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0); const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false); const [confirmingClose, setConfirmingClose] = useState(false);
const [liftedKey, setLiftedKey] = useState<string | null>(null);
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
const panelRef = useRef<HTMLElement>(null); const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null); const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]'); const baselineRef = useRef('[]');
const rulesRef = useRef<DraftRule[]>(rules);
const dragRef = useRef<RuleDragSession | null>(null);
const suppressHandleClickRef = useRef(false);
rulesRef.current = rules;
const savedRules = route?.localRules || []; const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || []; 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 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 const pendingCount = pendingRestart
? savedRules.filter((rule) => ( ? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0; : 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); const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => { useEffect(() => {
@@ -153,6 +209,11 @@ export function useRoutingFeature({
if (event.type === 'keydown') { if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent; const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return; if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
if (dragRef.current?.lifted) {
keyboardEvent.preventDefault();
cancelReorder();
return;
}
} else { } else {
if (panelRef.current?.contains(event.target as Node)) return; if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.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]); }, [isOpen, dirty]);
useEffect(() => () => {
const session = dragRef.current;
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
cancelAnimationFrame(session.raf);
}
dragRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
if (!isOpen || !dirty) return undefined; if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => { const warnBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -178,8 +247,10 @@ export function useRoutingFeature({
}, [isOpen, dirty]); }, [isOpen, dirty]);
function open() { function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled }))); const nextRules = savedRules.map(createLocalRuleDraft);
setRules(savedRules.map(createLocalRuleDraft)); baselineRef.current = localRulesSignature(savedRules);
rulesRef.current = nextRules;
setRules(nextRules);
setRevision(route?.localRulesRevision || 0); setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false); setConfirmingClose(false);
onDismissError(); onDismissError();
@@ -187,11 +258,13 @@ export function useRoutingFeature({
} }
function forceClose() { function forceClose() {
cancelReorder(false);
setIsOpen(false); setIsOpen(false);
} }
function requestClose() { function requestClose() {
if (dirty) { const currentRules = cancelReorder(false);
if (localRulesSignature(currentRules) !== baselineRef.current) {
setConfirmingClose(true); setConfirmingClose(true);
return false; return false;
} }
@@ -200,36 +273,52 @@ export function useRoutingFeature({
} }
function discard() { function discard() {
cancelReorder(false);
setConfirmingClose(false); setConfirmingClose(false);
setIsOpen(false); setIsOpen(false);
} }
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) { function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
setRules((current) => current.map((rule, ruleIndex) => ( setRules((current) => {
const next = updater(current);
rulesRef.current = next;
return next;
});
}
function change(
index: number,
field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled' | 'outbound'>,
value: unknown,
) {
if (!editable || blocked) return;
updateRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
))); )));
} }
function add() { function add() {
setRules((current) => [ if (!editable || blocked) return;
updateRules((current) => [
...current, ...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }), createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
]); ]);
} }
function remove(ruleKey: string) { function remove(ruleKey: string) {
if (!editable || blocked) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey)); updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
return; return;
} }
setRules((current) => current.map((rule) => ( updateRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule rule._key === ruleKey ? { ...rule, removing: true } : rule
))); )));
} }
function finishRemove(ruleKey: string) { function finishRemove(ruleKey: string) {
const update = () => flushSync(() => { 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) { if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update(); update();
@@ -240,25 +329,298 @@ export function useRoutingFeature({
async function save(event: FormEvent<HTMLFormElement>) { async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); 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)); const result = routingSaveState(await onSave(values, revision));
if (!result) return; if (!result) return;
baselineRef.current = JSON.stringify(values); baselineRef.current = localRulesSignature(values);
setRevision(result.localRulesRevision); setRevision(result.localRulesRevision);
setConfirmingClose(false); setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(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<HTMLElement>('[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<HTMLButtonElement>, 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<HTMLElement>('[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<HTMLButtonElement>) {
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<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
dropReorder();
}
function cancelPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
cancelReorder();
}
function losePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
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<HTMLButtonElement>, 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<HTMLButtonElement>, 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<HTMLButtonElement>, ruleKey: string) {
const session = dragRef.current;
if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) {
finishReorder('focus-leave');
}
}
return { return {
isOpen, isOpen,
rules, rules,
savedRules, savedRules,
activeRules, activeRules,
connected, connected,
editable,
bypassed,
dirty, dirty,
pendingRestart, pendingRestart,
savedUntilStart,
pendingCount, pendingCount,
pendingMessage,
blocked, blocked,
liftedKey,
reorderAnnouncement,
confirmingClose, confirmingClose,
panelRef, panelRef,
toggleRef, toggleRef,
@@ -272,6 +634,14 @@ export function useRoutingFeature({
add, add,
remove, remove,
finishRemove, finishRemove,
startPointerReorder,
movePointerReorder,
finishPointerReorder,
cancelPointerReorder,
losePointerReorder,
handleReorderKey,
handleReorderClick,
handleReorderBlur,
save, 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 <div className="client-rule-outbound" role="group" aria-label={`Маршрут правила ${index + 1}`}>
{([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => (
<button
type="button"
key={outbound}
aria-pressed={value === outbound}
disabled={disabled}
onClick={() => onChange(outbound)}
>
{label}
</button>
))}
</div>;
}
export function RoutingToggle({ export function RoutingToggle({
feature, feature,
open, open,
@@ -394,7 +790,7 @@ export function RoutingToggle({
hasSubscription: boolean; hasSubscription: boolean;
onOpen: () => void; onOpen: () => void;
}) { }) {
const disabled = gatewayDirect || (isGateway && !hasSubscription); const disabled = isGateway && !hasSubscription;
return ( return (
<RailAction <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
@@ -403,15 +799,13 @@ export function RoutingToggle({
controls="client-local-rules" controls="client-local-rules"
disabled={disabled} disabled={disabled}
ariaLabel={disabled ariaLabel={disabled
? hasSubscription ? 'Правила маршрутизации недоступны: сначала добавьте подписку'
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway' : gatewayDirect
: 'Локальные правила недоступны: сначала добавьте подписку' ? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
: open ? 'Закрыть локальные правила' : 'Настроить локальные правила'} : open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
label={disabled label={disabled
? hasSubscription ? 'Сначала добавьте подписку'
? 'Локальные правила недоступны: сейчас работают правила Gateway' : feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
onClick={() => open ? feature.requestClose() : onOpen()} onClick={() => open ? feature.requestClose() : onOpen()}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -434,10 +828,10 @@ export function RoutingPendingStatus({
blocked: boolean; blocked: boolean;
onRestart: () => unknown; onRestart: () => unknown;
}) { }) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite"> return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && ( {feature.pendingMessage && (
<> <>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span> <span>{feature.pendingMessage}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button> <button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</> </>
)} )}
@@ -446,7 +840,8 @@ export function RoutingPendingStatus({
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) { export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing); 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()); const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return ( return (
@@ -458,7 +853,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
sheetClassName="client-local-rules-sheet" sheetClassName="client-local-rules-sheet"
open={feature.isOpen} open={feature.isOpen}
labelledBy="local-rules-title" labelledBy="local-rules-title"
closeLabel="Закрыть локальные правила" closeLabel="Закрыть правила маршрутизации"
onClose={feature.requestClose} onClose={feature.requestClose}
> >
<header className="client-local-rules-header"> <header className="client-local-rules-header">
@@ -467,43 +862,92 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
className="client-local-rules-save" className="client-local-rules-save"
type="submit" type="submit"
form="client-local-rules-form" form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty} disabled={editorDisabled || !feature.dirty}
> >
Сохранить Сохранить
</button> </button>
<h2 id="local-rules-title">Локальные правила</h2> <h2 id="local-rules-title">Правила маршрутизации</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p> <p>Проверяются сверху вниз. Первое совпадение выбирает маршрут.</p>
{feature.pendingRestart && ( {!feature.editable && (
<p className="client-local-rules-runtime" role="status">
Обновите Harbor, чтобы редактировать правила.
</p>
)}
{feature.bypassed && (
<p className="client-local-rules-runtime" role="status">
Локальный список сейчас обходится Harbor Gateway.
</p>
)}
{!feature.bypassed && feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status"> <p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box. Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p> </p>
)} )}
{feature.savedUntilStart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены и начнут работать после запуска Harbor.
</p>
)}
</header> </header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}> <form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<span id="client-rule-reorder-instructions" className="client-rule-reorder-instructions">
Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет.
</span>
<span className="client-rule-reorder-live" role="status" aria-live="polite" aria-atomic="true">
{feature.reorderAnnouncement}
</span>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title"> <section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span> <span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list"> <div className="client-local-rules-list">
{feature.rules.map((rule, index) => { {feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus( const [status, statusLabel] = localRuleStatus(
rule, rule,
index,
feature.savedRules, feature.savedRules,
feature.activeRules, feature.activeRules,
feature.connected, feature.connected,
feature.bypassed,
); );
const lifted = feature.liftedKey === rule._key;
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
return ( return (
<div <div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`} className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
key={rule._key} key={rule._key}
data-rule-key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }} style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined} inert={rule.removing ? true : undefined}
> >
<button
className="client-rule-handle"
type="button"
aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
aria-describedby="client-rule-reorder-instructions"
aria-pressed={lifted}
disabled={handleDisabled}
onPointerDown={(event) => feature.startPointerReorder(event, rule._key)}
onPointerMove={feature.movePointerReorder}
onPointerUp={feature.finishPointerReorder}
onPointerCancel={feature.cancelPointerReorder}
onLostPointerCapture={feature.losePointerReorder}
onKeyDown={(event) => feature.handleReorderKey(event, rule._key)}
onClick={(event) => feature.handleReorderClick(event, rule._key)}
onBlur={(event) => feature.handleReorderBlur(event, rule._key)}
>
<svg viewBox="0 0 12 28" aria-hidden="true">
<circle cx="6" cy="6" r="1.6" />
<circle cx="6" cy="14" r="1.6" />
<circle cx="6" cy="22" r="1.6" />
</svg>
</button>
<button <button
className="client-local-rule-enabled" className="client-local-rule-enabled"
type="button" type="button"
role="switch" role="switch"
aria-checked={rule.enabled} aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`} aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.change(index, 'enabled', !rule.enabled)} onClick={() => feature.change(index, 'enabled', !rule.enabled)}
> >
<svg viewBox="0 0 20 20" aria-hidden="true"> <svg viewBox="0 0 20 20" aria-hidden="true">
@@ -515,7 +959,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
value={rule.type} value={rule.type}
ruleKey={rule._key} ruleKey={rule._key}
index={index} index={index}
disabled={rule.removing} disabled={editorDisabled || rule.removing}
onChange={(type) => feature.change(index, 'type', type)} onChange={(type) => feature.change(index, 'type', type)}
/> />
<input <input
@@ -524,16 +968,17 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
required required
disabled={editorDisabled}
aria-label={`Значение правила ${index + 1}`} aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]} placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value} value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)} onChange={(event) => feature.change(index, 'value', event.target.value)}
/> />
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button <button
className="client-row-delete" className="client-row-delete"
type="button" type="button"
aria-label={`Удалить правило ${index + 1}`} aria-label={`Удалить правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.remove(rule._key)} onClick={() => feature.remove(rule._key)}
> >
× ×
@@ -543,10 +988,19 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
aria-hidden="true" aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)} onAnimationEnd={() => feature.finishRemove(rule._key)}
/> />
<div className="client-local-rule-meta">
<RuleOutboundPicker
value={rule.outbound}
index={index}
disabled={editorDisabled}
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
</div>
</div> </div>
); );
})} })}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>} {!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Harbor использует выбранный режим соединения.</p>}
</div> </div>
<div className="client-row-add-slot"> <div className="client-row-add-slot">
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}> <button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
@@ -556,6 +1010,9 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
</div> </div>
</section> </section>
<p className="client-local-rules-note">
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила результат уже найденного совпадения.
</p>
<p className="client-local-rules-note"> <p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации. Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p> </p>
@@ -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<T>(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<T>(
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;
}
+114 -4
View File
@@ -76,16 +76,18 @@
} }
.client-local-rule { .client-local-rule {
--client-rule-drag-y: 0px;
position: relative; position: relative;
isolation: isolate; isolation: isolate;
min-width: 0; min-width: 0;
display: grid; 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; align-items: center;
gap: 10px; column-gap: 8px;
row-gap: 2px;
padding: 7px 0; padding: 7px 0;
animation: client-row-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both; 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) { .client-local-rule:has(.client-rule-type.is-open) {
@@ -98,6 +100,7 @@
letter-spacing: var(--type-label-tracking); letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform); text-transform: var(--type-label-transform);
text-align: right; text-align: right;
white-space: nowrap;
transition: color 280ms ease, opacity 280ms ease, filter 360ms ease; transition: color 280ms ease, opacity 280ms ease, filter 360ms ease;
} }
@@ -110,11 +113,19 @@
color: var(--client-warning, oklch(0.72 0.12 72)); 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; opacity: 0.42;
filter: saturate(0); 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 { .client-local-rule.is-removing {
pointer-events: none; pointer-events: none;
animation: client-row-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both; animation: client-row-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
@@ -132,6 +143,44 @@
cursor: pointer; 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 { .client-local-rule-enabled svg {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -312,6 +361,67 @@
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent); 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 { .client-local-rules-empty {
padding: 14px 4px 4px; padding: 14px 4px 4px;
color: var(--client-muted); color: var(--client-muted);
+46 -13
View File
@@ -629,15 +629,6 @@
} }
@media (max-width: 560px) { @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 { .client-mode .app-main {
padding: 20px 14px; padding: 20px 14px;
} }
@@ -713,15 +704,27 @@
} }
.client-local-rule { .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-local-rule-enabled,
.client-row-delete { .client-row-delete {
width: 44px; width: 44px;
height: 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 { .client-instructions-sheet {
padding: 40px 58px 60px 18px; padding: 40px 58px 60px 18px;
} }
@@ -734,26 +737,36 @@
gap: 8px; gap: 8px;
} }
.client-local-rule-enabled { .client-rule-handle {
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
} }
.client-rule-type { .client-local-rule-enabled {
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
} }
.client-row-delete { .client-rule-type {
grid-column: 3; grid-column: 3;
grid-row: 1; grid-row: 1;
} }
.client-row-delete {
grid-column: 4;
grid-row: 1;
}
.client-local-rule input { .client-local-rule input {
grid-column: 2 / -1; grid-column: 2 / -1;
grid-row: 2; grid-row: 2;
} }
.client-local-rule-meta {
grid-column: 2 / -1;
grid-row: 3;
}
.client-instruction-block:nth-child(n) { .client-instruction-block:nth-child(n) {
margin-inline: 0; 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;
}
}
+2
View File
@@ -314,8 +314,10 @@
transform: rotate(8deg) scale(1.1); transform: rotate(8deg) scale(1.1);
} }
.client-rule-handle:focus-visible,
.client-local-rule-enabled:focus-visible, .client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible, .client-rule-type-trigger:focus-visible,
.client-rule-outbound button:focus-visible,
.client-row-delete:focus-visible, .client-row-delete:focus-visible,
.client-row-add:focus-visible, .client-row-add:focus-visible,
.client-local-rules-save:focus-visible, .client-local-rules-save:focus-visible,
+2
View File
@@ -122,6 +122,7 @@
} }
.client-local-rule, .client-local-rule,
.client-rule-handle,
.client-local-rule-enabled svg, .client-local-rule-enabled svg,
.client-local-rule-enabled circle, .client-local-rule-enabled circle,
.client-local-rule-enabled path, .client-local-rule-enabled path,
@@ -130,6 +131,7 @@
.client-rule-type-list, .client-rule-type-list,
.client-rule-type-list button, .client-rule-type-list button,
.client-local-rule input, .client-local-rule input,
.client-rule-outbound button,
.client-row-delete, .client-row-delete,
.client-row-add, .client-row-add,
.client-row-add-slot > span, .client-row-add-slot > span,
+15 -2
View File
@@ -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 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 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]) => ({ const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
id, 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.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
assert.equal(after.state.appliedProfileId, 'work'); assert.equal(after.state.appliedProfileId, 'work');
assert.equal(after.state.appliedServerId, 'a'); assert.equal(after.state.appliedServerId, 'a');
assert.deepEqual(after.state.appliedRouteRules, rules);
assert.deepEqual(after.state.appliedServerSnapshot, serverA); assert.deepEqual(after.state.appliedServerSnapshot, serverA);
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']); 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 () => { 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'); await harness.service.apply('work', 'b');
const after = harness.snapshot(); const after = harness.snapshot();
assert.equal(after.state.desiredProfileId, 'work'); assert.equal(after.state.desiredProfileId, 'work');
assert.equal(after.state.appliedProfileId, 'personal'); assert.equal(after.state.appliedProfileId, 'personal');
assert.deepEqual(after.state.appliedRouteRules, []);
assert.equal(harness.events.includes('config.write'), false); assert.equal(harness.events.includes('config.write'), false);
assert.equal(harness.events.includes('runtime.start'), 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.appliedProfileId, 'work');
assert.equal(stopped.snapshot().state.appliedServerId, 'b'); assert.equal(stopped.snapshot().state.appliedServerId, 'b');
assert.match(stopped.snapshot().config, /"selectedServerId":"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 () => { test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
+18 -2
View File
@@ -27,6 +27,10 @@ const storedProfile = (servers = [server]) => ({
lastRefreshAttemptAt: null, lastRefreshAttemptAt: null,
lastRefreshErrorCode: 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()]) => ({ const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10, revision: 10,
profiles, profiles,
@@ -34,8 +38,8 @@ const canonicalState = (profiles = [storedProfile()]) => ({
appliedProfileId: profiles[0]?.id || '', appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '', appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null, appliedServerSnapshot: profiles[0]?.servers[0] || null,
routeRules: [], routeRules,
appliedRouteRules: [], appliedRouteRules: routeRules,
routeRulesRevision: 0, routeRulesRevision: 0,
gatewayAutoEnabled: true, gatewayAutoEnabled: true,
}); });
@@ -277,6 +281,18 @@ test('gateway-auto startup writes candidate config before publication without ap
'state.update', 'state.update',
'route:gateway-direct', '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 () => { test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
+15 -1
View File
@@ -136,10 +136,17 @@ test('verified Gateway stays active through transient discovery failures', () =>
}); });
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => { 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 = { const storedState = {
revision: 1, revision: 1,
subscriptionUrl, subscriptionUrl,
gatewayAutoEnabled: true, gatewayAutoEnabled: true,
routeRules: desiredRules,
appliedRouteRules: appliedRules,
}; };
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({ const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
storedState: stored, 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.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
assert.equal(found.autoEnabled, true); assert.equal(found.autoEnabled, true);
assert.equal(found.fallbackPreference, 'local-vpn'); 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, failures: 1 }).reason, 'gateway-stale');
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost'); 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(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', () => { test('client can ignore and restore a verified Gateway without losing discovery', () => {
+47 -26
View File
@@ -5,8 +5,8 @@ import test from 'node:test';
import { createRouteRulesService } from '../../dist/server/features/routing/index.js'; import { createRouteRulesService } from '../../dist/server/features/routing/index.js';
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js'; import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }]; const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true, outbound: 'direct' }];
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }]; 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 server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({ const storedProfile = (servers = [server]) => ({
id: 'primary', id: 'primary',
@@ -78,7 +78,7 @@ function createHarness(overrides = {}) {
write: (value) => { write: (value) => {
events.push('config.write'); events.push('config.write');
if (failures.configWrite) throw failures.configWrite; if (failures.configWrite) throw failures.configWrite;
config = JSON.stringify(value); config = JSON.stringify(value, null, 2);
if (failures.configWriteAfter) throw failures.configWriteAfter; if (failures.configWriteAfter) throw failures.configWriteAfter;
}, },
restore: (value) => { restore: (value) => {
@@ -103,6 +103,7 @@ function createHarness(overrides = {}) {
if (failures.runtimeRestore) throw failures.runtimeRestore; if (failures.runtimeRestore) throw failures.runtimeRestore;
}, },
}, },
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
serialize, serialize,
runOperation: async (operation) => { runOperation: async (operation) => {
events.push('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 () => { test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => {
const harness = createHarness(); const harness = createHarness();
assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID'); assert.throws(() => harness.service.update(newRules, 2, undefined), (error) => error.code === 'REQUEST_INVALID');
assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID'); assert.throws(() => harness.service.update('bad', 2, 2), (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, -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(); 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.snapshot(), before);
assert.deepEqual(harness.events, []); 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(); const explicit = createHarness();
await explicit.service.update([ await explicit.service.update([
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true }, { type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'new.example', enabled: true }, { type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
], 2, undefined); ], 2, 2);
assert.deepEqual(explicit.snapshot().state.routeRules, newRules); assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
assert.equal(explicit.snapshot().state.routeRulesRevision, 3); assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
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 () => { 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 }, { ...canonicalState(), revision: 1, routeRulesRevision: 0 },
]) { ]) {
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) }); 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.routeRules, newRules);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules); assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
assert.equal(harness.snapshot().config, 'old-config'); 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 () => { test('route rules running apply updates active rules while stopped leaves them pending', async () => {
const running = createHarness({ running: true }); 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.snapshot().state.appliedRouteRules, newRules);
assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']); assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']);
const stopped = createHarness({ running: false }); 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.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules);
assert.equal(stopped.events.includes('runtime.apply'), false); 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 () => { test('route rules rollback restores config/domain and honors runtime mutation phase', async () => {
for (const failures of [ for (const failures of [
{ configWriteAfter: new Error('config') }, { 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 harness = createHarness({ failures });
const before = harness.snapshot(); 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); assertDomainRestored(harness.snapshot(), before);
} }
const preMutation = new Error('invalid config'); const preMutation = new Error('invalid config');
const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } }); 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); assert.equal(local.events.includes('runtime.restore'), false);
const postMutation = new Error('remote failed'); const postMutation = new Error('remote failed');
const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } }); 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); 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({ const aggregate = createHarness({
failures: { stateUpdates: [original], configRestore }, 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.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, configRestore]); assert.deepEqual(error.errors, [original, configRestore]);
return true; return true;
@@ -217,7 +238,7 @@ test('route rules rollback continues and classifies runtime restore failure', as
const broken = createHarness({ const broken = createHarness({
failures: { stateUpdates: [original], runtimeRestore }, 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.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [original, runtimeRestore]); assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
return true; return true;
@@ -229,13 +250,13 @@ test('route rules route preserves one adapter and state-only response', async ()
const calls = []; const calls = [];
const route = createRouteRulesRoute({ const route = createRouteRulesRoute({
routeRules: { update: async (...args) => { calls.push(args); } }, 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'); }, sendState: async () => { calls.push('sent'); },
}); });
const response = {}; const response = {};
assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false); 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.equal(await route.handle({ method: 'PUT', url: '/api/route-rules/v2' }, response), true);
assert.deepEqual(calls, [[newRules, 3, 99], 'sent']); assert.deepEqual(calls, [[newRules, 3, 2], 'sent']);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8'); const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createRouteRulesRoute\(\{/); assert.match(source, /createRouteRulesRoute\(\{/);
+11 -8
View File
@@ -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', { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [ routeRules: [
{ type: 'domain_suffix', value: 'ru', enabled: true }, { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain', value: 'example.com', enabled: true }, { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
{ type: 'domain_keyword', value: 'cdn', enabled: false }, { 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', timeout: '1s',
}, },
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' }, { domain: ['api.example.com'], outbound: 'test-vpn' },
{ domain: ['example.com'], outbound: 'direct' }, { domain_suffix: ['example.com'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' },
]); ]);
assert.equal(config.route.final, 'test-vpn'); assert.equal(config.route.final, 'test-vpn');
@@ -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', () => { test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
clientDirect: true, 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, [ 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', timeout: '1s',
}, },
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'direct' }, { inbound: ['mixed-in'], outbound: 'direct' },
]); ]);
assert.equal(config.route.final, 'direct'); assert.equal(config.route.final, 'direct');
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['test-vpn', '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);
}); });
+8 -3
View File
@@ -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', { 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, []); assert.deepEqual(config.route.rule_set, []);
@@ -47,7 +51,8 @@ test('gateway routes .ru domains directly and other traffic through the selected
timeout: '1s', timeout: '1s',
}, },
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { 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: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' },
]); ]);
+89 -3
View File
@@ -6,6 +6,9 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; 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, '../..'); const root = path.resolve(import.meta.dirname, '../..');
function listen(server, ...args) { function listen(server, ...args) {
@@ -72,7 +75,7 @@ if (process.argv[2] === 'run') {
function profileState(server) { function profileState(server) {
return { return {
schemaVersion: 5, schemaVersion: 6,
revision: 4, revision: 4,
profiles: [{ profiles: [{
id: 'profile-a', id: 'profile-a',
@@ -116,6 +119,8 @@ async function startClientFixture(t, {
state, state,
cacheContents, cacheContents,
config, config,
hostNetwork,
gatewayPresencePort,
}) { }) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory); const { binDirectory, markerPath } = fakeSingbox(directory);
@@ -126,6 +131,8 @@ async function startClientFixture(t, {
if (config !== undefined) { if (config !== undefined) {
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config)); 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 port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], { const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root, cwd: root,
@@ -136,7 +143,10 @@ async function startClientFixture(t, {
DATA_DIR: directory, DATA_DIR: directory,
PORT: String(port), PORT: String(port),
PATH: `${binDirectory}:${process.env.PATH || ''}`, 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, HARBOR_TEST_RUN_MARKER: markerPath,
}, },
stdio: ['ignore', 'ignore', 'pipe'], 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) => { test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
const staleServer = { const staleServer = {
id: 'stale-server', id: 'stale-server',
@@ -298,7 +384,7 @@ test('stopped Gateway boot explicitly stops an already running remote dataplane'
}); });
await listen(dataplane, socketPath); await listen(dataplane, socketPath);
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({ fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
schemaVersion: 5, schemaVersion: 6,
revision: 3, revision: 3,
profiles: [], profiles: [],
desiredProfileId: '', desiredProfileId: '',
+45 -19
View File
@@ -90,6 +90,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot',
appliedServerSnapshot: snapshot.profiles[0].servers[0], appliedServerSnapshot: snapshot.profiles[0].servers[0],
}); });
assert.equal(snapshot.connection.process, 'running'); assert.equal(snapshot.connection.process, 'running');
assert.equal(snapshot.route.rulesContractVersion, 2);
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false); assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
assert.throws( assert.throws(
() => assertStateSnapshot({ ...snapshot, revision: -1 }), () => 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: '0.0.0.0',
server_port: 1, 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({ fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl, subscriptionUrl,
selectedTag: rejectedServer.tag, selectedTag: rejectedServer.tag,
@@ -296,14 +297,15 @@ setInterval(() => {}, 60_000);
assert.equal(initial.selection.appliedProfileId, 'profile_primary'); assert.equal(initial.selection.appliedProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId); assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
assert.deepEqual(initial.route.localRules, [ 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.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
assert.equal(initial.route.localRulesRevision, 0); assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false); assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false); assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); 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.length, 1);
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl); assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
assert.equal(migratedState.profiles[0].desiredServerId, testServerId); 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/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
let routed = await mutation('/api/route-rules', 'PUT', { let routed = await mutation('/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision, expectedRulesRevision: rulesRevision,
rulesContractVersion: 2,
rules: [ rules: [
{ type: 'domain_suffix', value: 'ru', enabled: false }, { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true }, { type: 'domain', value: 'https://Example.com/private?q=1', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: '*.Example.org', enabled: true }, { type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' },
], ],
}); });
rulesRevision = routed.state.route.localRulesRevision; rulesRevision = routed.state.route.localRulesRevision;
assert.deepEqual(routed.state.route.localRules, [ assert.deepEqual(routed.state.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: false }, { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
{ type: 'domain', value: 'example.com', enabled: true }, { type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.org', enabled: true }, { type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
]); ]);
assert.equal(routed.state.route.localRulesPendingRestart, false); assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules); assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
@@ -545,16 +548,17 @@ setInterval(() => {}, 60_000);
timeout: '1s', timeout: '1s',
}, },
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId }, { inbound: ['diagnostics-vpn-in'], outbound: testServerId },
{ domain: ['example.com'], outbound: 'direct' }, { domain: ['example.com'], outbound: testServerId },
{ domain_suffix: ['example.org'], outbound: 'direct' }, { domain_suffix: ['example.org'], outbound: 'direct' },
]); ]);
await mutation('/api/singbox/stop'); await mutation('/api/singbox/stop');
routed = await mutation('/api/route-rules', 'PUT', { routed = await mutation('/api/route-rules/v2', 'PUT', {
expectedRulesRevision: rulesRevision, expectedRulesRevision: rulesRevision,
rulesContractVersion: 2,
rules: [ rules: [
...routed.state.route.localRules, ...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; rulesRevision = routed.state.route.localRulesRevision;
@@ -565,27 +569,48 @@ setInterval(() => {}, 60_000);
assert.equal(restartedRules.state.route.localRulesPendingRestart, false); assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules); 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, 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.response.status, 400);
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID'); assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
assert.equal((await request(port, '/api/state')).revision, revision); 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, expectedRulesRevision: 0,
rulesContractVersion: 2,
rules: [], rules: [],
}); });
assert.equal(staleRules.response.status, 409); assert.equal(staleRules.response.status, 409);
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT'); assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules); 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', { const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRevision: revision, expectedRevision: revision,
rules: routed.state.route.localRules, 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'); const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node fs.writeFileSync(singboxPath, `#!/usr/bin/env node
@@ -599,9 +624,10 @@ process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000); setInterval(() => {}, 60_000);
`); `);
fs.chmodSync(singboxPath, 0o755); 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, 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.response.status, 422);
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID'); assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
+56 -1
View File
@@ -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.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [ 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]; const primary = migrated.profiles[0];
assert.equal(primary.label, 'Основной'); 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); 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) => { test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const filePath = fixture(t); const filePath = fixture(t);
fs.writeFileSync(filePath, JSON.stringify({ fs.writeFileSync(filePath, JSON.stringify({
+22 -3
View File
@@ -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 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 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) => ({ const profile = (id, label, server = oldServer) => ({
id, id,
@@ -47,7 +47,7 @@ function defaultState() {
function createHarness(overrides = {}) { function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? defaultState()); let state = structuredClone(overrides.state ?? defaultState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config'; 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 running = overrides.running ?? true;
let tail = Promise.resolve(); let tail = Promise.resolve();
let updateCount = 0; 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); 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 () => { test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => {
const inactive = createHarness(); const inactive = createHarness();
await inactive.service.deleteProfile('work', 'delete', 3); 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(); const after = harness.snapshot();
assert.equal(after.config, 'old-config'); assert.equal(after.config, 'old-config');
assert.equal(after.running, true); 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); assert.equal(harness.calls.includes('gateway.set'), false);
}); });
+30 -11
View File
@@ -5,16 +5,16 @@ import { canAppendRouteRule, normalizeRouteRules } from '../../dist/shared/routi
test('local route rules normalize URLs, suffixes and duplicates', () => { test('local route rules normalize URLs, suffixes and duplicates', () => {
assert.deepEqual(normalizeRouteRules([ assert.deepEqual(normalizeRouteRules([
{ type: 'domain', value: 'https://Example.com/news?id=1' }, { type: 'domain', value: 'https://Example.com/news?id=1', outbound: 'vpn' },
{ type: 'domain_suffix', value: '*.Example.org' }, { type: 'domain_suffix', value: '*.Example.org', outbound: 'direct' },
{ type: 'domain_keyword', value: ' CDN ' }, { type: 'domain_keyword', value: ' CDN ', outbound: 'vpn' },
{ type: 'domain', value: 'example.com' }, { type: 'domain', value: 'example.com', outbound: 'direct' },
{ type: 'domain_suffix', value: '.ru', enabled: false }, { type: 'domain_suffix', value: '.ru', enabled: false, outbound: 'direct' },
], { strict: true }), [ ], { strict: true }), [
{ type: 'domain', value: 'example.com', enabled: true }, { type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'example.org', enabled: true }, { type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
{ type: 'domain_keyword', value: 'cdn', enabled: true }, { type: 'domain_keyword', value: 'cdn', enabled: true, outbound: 'vpn' },
{ type: 'domain_suffix', value: 'ru', enabled: false }, { type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
]); ]);
assert.equal(canAppendRouteRule([{ value: 'filled' }]), true); assert.equal(canAppendRouteRule([{ value: 'filled' }]), true);
assert.equal(canAppendRouteRule([{ value: '' }]), false); 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', () => { test('invalid local route rules fail at the strict boundary', () => {
assert.throws( assert.throws(
() => normalizeRouteRules([{ type: 'domain_regex', value: '.*' }], { strict: true }), () => normalizeRouteRules([{ type: 'domain_regex', value: '.*', outbound: 'direct' }], { strict: true }),
/Invalid domain rule type/, /Invalid domain rule type/,
); );
assert.throws( 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/, /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' },
]);
}); });
+3 -2
View File
@@ -93,11 +93,12 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
[() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', { [() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
method: 'POST', body: JSON.stringify({ enabled: true }), 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', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
rules: [{ type: 'domain', value: 'example.com' }], rules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }],
expectedRulesRevision: 7, expectedRulesRevision: 7,
rulesContractVersion: 2,
}), }),
}], }],
[() => api.devices.list(), '/api/devices', {}], [() => api.devices.list(), '/api/devices', {}],
+24
View File
@@ -67,6 +67,30 @@ test('typed Harbor client validates unknown state and isolates wire compatibilit
assert.equal(failed.transport.bootStatus, 'incompatible-api'); 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 () => { test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => {
let state = receive(initialHarborState, snapshot(1, 'one')); let state = receive(initialHarborState, snapshot(1, 'one'));
const poll = deferred(); const poll = deferred();
+18 -1
View File
@@ -132,7 +132,8 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(component, /<ConnectivityDiagnosticsPanel/); assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/); assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/); assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
assert.match(routing, /Локальные правила недоступны: сейчас работают правила Gateway/); assert.doesNotMatch(routing, /const disabled = gatewayDirect \|\|/);
assert.match(routing, /Локальный список сейчас обходится Harbor Gateway/);
assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/); assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/);
assert.match(rule('.client-secondary-menu'), /display:\s*grid/); assert.match(rule('.client-secondary-menu'), /display:\s*grid/);
assert.match(disabledRulesLabel, /opacity:\s*0/); assert.match(disabledRulesLabel, /opacity:\s*0/);
@@ -192,6 +193,22 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.doesNotMatch(component, /instructionsFeature\.close\(\)[\s\S]{0,180}devicesFeature\.close\(\)[\s\S]{0,180}subscriptionFeature\.toggle\(\)/); assert.doesNotMatch(component, /instructionsFeature\.close\(\)[\s\S]{0,180}devicesFeature\.close\(\)[\s\S]{0,180}subscriptionFeature\.toggle\(\)/);
}); });
test('routing rules keep two desktop rows and three narrow rows without shrinking drag targets', () => {
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', () => { test('connectivity diagnostics render stable compact tables before the first run', () => {
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/); assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/); assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/);
+21 -4
View File
@@ -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', () => { test('routing controller preserves snapshot drafts, live status and guarded close/save semantics', () => {
assert.match(feature, /const savedRules = route\?\.localRules \|\| \[\]/); assert.match(feature, /const savedRules = route\?\.localRules \|\| \[\]/);
assert.match(feature, /baselineRef\.current = JSON\.stringify\(savedRules\.map/); assert.match(feature, /baselineRef\.current = localRulesSignature\(savedRules\)/);
assert.match(feature, /setRules\(savedRules\.map\(createLocalRuleDraft\)\)/); assert.match(feature, /const nextRules = savedRules\.map\(createLocalRuleDraft\)/);
assert.match(feature, /setRevision\(route\?\.localRulesRevision \|\| 0\)/); 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, /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, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
assert.match(feature, /Number\.isSafeInteger\(localRulesRevision\)[\s\S]*localRulesRevision as number\) < 0[\s\S]*typeof localRulesPendingRestart !== 'boolean'/); 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', () => { test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => {
assert.match(feature, /keyboardEvent\.key !== 'Escape' \|\| keyboardEvent\.defaultPrevented/); assert.match(feature, /keyboardEvent\.key !== 'Escape' \|\| keyboardEvent\.defaultPrevented/);
assert.match(feature, /addEventListener\('beforeunload', warnBeforeUnload\)/); assert.match(feature, /addEventListener\('beforeunload', warnBeforeUnload\)/);
+18 -3
View File
@@ -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, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
assert.match(routing, /Ждёт перезапуска/); assert.match(routing, /Ждёт перезапуска/);
assert.match(routing, /Перезапустить VPN/); 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, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
assert.match(routing, /client-deletable-row/); assert.match(routing, /client-deletable-row/);
assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/); 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, /ГОТОВО/);
assert.doesNotMatch(component, />Error<|>Copied</); assert.doesNotMatch(component, />Error<|>Copied</);
assert.match(instructions, /closeLabel="Закрыть инструкции"/); assert.match(instructions, /closeLabel="Закрыть инструкции"/);
assert.match(routing, /closeLabel="Закрыть локальные правила"/); assert.match(routing, /closeLabel="Закрыть правила маршрутизации"/);
assert.match(instructions, /closeRef\.current\?\.focus\(\)/); assert.match(instructions, /closeRef\.current\?\.focus\(\)/);
assert.match(routing, /closeRef\.current\?\.focus\(\)/); assert.match(routing, /closeRef\.current\?\.focus\(\)/);
assert.match(connection, /ariaLabel={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/); assert.match(connection, /ariaLabel={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/); assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/); assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/); assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/); assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-rule-handle,[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
});
test('ordered rules use one accessible drag handle and a fixed two-target control', () => {
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, /<svg viewBox="0 0 12 28" aria-hidden="true">[\s\S]*<circle[\s\S]*<circle[\s\S]*<circle/);
assert.match(routing, /client-rule-reorder-instructions[\s\S]*стрелки вверх и вниз[\s\S]*Escape отменяет/);
assert.match(routing, /client-rule-reorder-live[\s\S]*aria-live="polite"/);
assert.match(routing, /className="client-rule-outbound" role="group"[\s\S]*\['vpn', 'VPN'\][\s\S]*\['direct', 'Напрямую'\]/);
assert.doesNotMatch(routing, />\s*[↑↓]\s*</);
assert.match(styles, /\.client-rule-handle \{[\s\S]*width: 44px;[\s\S]*height: 44px;[\s\S]*touch-action: none/);
assert.match(styles, /\.client-rule-outbound \{[\s\S]*width: 128px/);
}); });
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
beginRuleReorder,
crossedRuleIndex,
edgeScrollDelta,
endRuleReorder,
keyboardRuleIndex,
moveRule,
restoreRuleOrder,
RULE_DROP_DURATION_MS,
RULE_REORDER_DURATION_MS,
shouldLiftRule,
} from '../../.test-dist/src/web/features/routing/ruleReorderModel.js';
test('rule drag lifts only after four vertical pixels', () => {
assert.equal(shouldLiftRule(100, 103), false);
assert.equal(shouldLiftRule(100, 104), true);
assert.equal(shouldLiftRule(100, 96), true);
assert.equal(shouldLiftRule(100, 100), false, 'horizontal-only movement leaves Y unchanged');
});
test('center crossing moves one or many slots without changing stable row identity', () => {
const rules = [{ _key: 'a' }, { _key: 'b' }, { _key: 'c' }];
assert.equal(crossedRuleIndex(0, 149, [100, 150, 200]), 0);
assert.equal(crossedRuleIndex(0, 151, [100, 150, 200]), 1);
assert.equal(crossedRuleIndex(0, 201, [100, 150, 200]), 2);
const moved = moveRule(rules, 0, 2);
assert.deepEqual(moved.map(({ _key }) => _key), ['b', 'c', 'a']);
assert.strictEqual(moved[2], rules[0]);
const edited = [{ _key: 'b', value: 2 }, { _key: 'a', value: 10 }, { _key: 'd', value: 4 }];
const restored = restoreRuleOrder(rules, edited, ({ _key }) => _key);
assert.deepEqual(restored.map(({ _key }) => _key), ['a', 'b', 'd']);
assert.equal(restored[0].value, 10, 'cancel restores order without reverting edits');
});
test('one lifecycle owner covers pointer, keyboard, cleanup and reduced motion', () => {
const pointer = beginRuleReorder(null, 'a', 'pointer');
assert.deepEqual(pointer, { key: 'a', input: 'pointer', lifted: false });
assert.equal(beginRuleReorder(pointer, 'b', 'keyboard'), null, 'a second handle cannot replace the session');
const liftedPointer = { ...pointer, lifted: true };
assert.deepEqual(endRuleReorder(liftedPointer, 'drop'), {
restoreOrder: false,
stopAutoScroll: true,
restoreFocus: true,
releasePointerCapture: true,
animateDrop: true,
});
assert.equal(endRuleReorder(liftedPointer, 'drop', true).animateDrop, false);
assert.equal(endRuleReorder(liftedPointer, 'lost-capture').releasePointerCapture, false);
assert.equal(endRuleReorder(liftedPointer, 'cancel').restoreOrder, true);
assert.equal(endRuleReorder(liftedPointer, 'unmount').restoreFocus, false);
const keyboard = beginRuleReorder(null, 'b', 'keyboard');
assert.deepEqual(keyboard, { key: 'b', input: 'keyboard', lifted: true });
const focusLeave = endRuleReorder(keyboard, 'focus-leave');
assert.equal(focusLeave.restoreOrder, true);
assert.equal(focusLeave.stopAutoScroll, true);
assert.equal(focusLeave.restoreFocus, false);
assert.equal(focusLeave.releasePointerCapture, false);
});
test('keyboard movement stops at list boundaries', () => {
assert.equal(keyboardRuleIndex(0, -1, 3), 0);
assert.equal(keyboardRuleIndex(0, 1, 3), 1);
assert.equal(keyboardRuleIndex(2, 1, 3), 2);
});
test('edge scrolling has direction, bounded speed and a zero outside its 32px zone', () => {
assert.equal(edgeScrollDelta(99, 100, 300), 0);
assert.equal(edgeScrollDelta(100, 100, 300), -12);
assert.ok(edgeScrollDelta(131, 100, 300) <= -2);
assert.equal(edgeScrollDelta(132, 100, 300), 0);
assert.equal(edgeScrollDelta(268, 100, 300), 0);
assert.ok(edgeScrollDelta(269, 100, 300) >= 2);
assert.equal(edgeScrollDelta(300, 100, 300), 12);
assert.equal(edgeScrollDelta(301, 100, 300), 0);
assert.equal(RULE_REORDER_DURATION_MS, 220);
assert.equal(RULE_DROP_DURATION_MS, 260);
});
+18 -18
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 820, cascadeEdges: 856,
customProperties: 105, customProperties: 106,
declarations: 3307, declarations: 3387,
important: 0, important: 0,
keyframes: 49, keyframes: 49,
media: 12, media: 13,
rules: 941, rules: 959,
variableReferences: 814, variableReferences: 829,
}, },
hashes: { hashes: {
cascadeEdges: 'a90bd0c489fe893ba65d6b4be6a56fdde8b9c8ba5a9ce606190c6eb95f7a25b1', cascadeEdges: '0aa9651d9c84e6274ee5f64cd3151304b612aff0a06858c3d86d935996a36216',
customProperties: 'dede9598ee62929fb29ff470897cd475ca34eb5af5dda342b09e306f3eaee887', customProperties: '06f39794d72566c2b2f8e4a2354866a54dbdceac0a1fdf9ae2619aae9abf2f36',
declarations: '782a758059d22fe094c2a972f315f5b7273987d5240a5d4c652959fe646f96aa', declarations: '09d0332d509ab6ae1d148a75ef5e931439eab7b29909529c8454382f02778fba',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e', duplicateSelectors: 'abdba98d777bbb19d76443af200ea2cc9018a11e5ef4ae2885ff2f5dd07ef178',
keyframes: '9e78309512ed82b1e9f87c58aeff505dfcdb694fc30c8f54570d01dce13eb51a', keyframes: '9e78309512ed82b1e9f87c58aeff505dfcdb694fc30c8f54570d01dce13eb51a',
ruleDeclarationSequences: '04efee8e9ff18e320bdb1c7c6981097a8d5bdf9fab5a461403425053cba4d172', ruleDeclarationSequences: '6e22245aef1e61fe8d56ad064d5e60d02696ec2af5446caacd66311973d5e02c',
selectors: '59d230e56506322b783962fbd2b86a1a0a3d08a3eb6ecba57f23ba20733a33c8', selectors: '6487fcc63dbe251f250b79dd1430542e366c598ce93208eebfe51c344dcac38a',
variableReferences: '67fbd84828e97d10d7fe163ebea6504e8c48355e99616ede2498307d836fa616', variableReferences: 'f9899b21c75649b687ebd660de6e3781aade3345e1f5bac49b0f6e235f63947d',
witnesses: '2f49f8c981cb3e6dbcdffd6bf3a436c8d66492546c2a1984853f9c9108e8d0ec', witnesses: '87efd05d7a1fd22bc4f421573b41a115a86b2587703266079f9c913e26c323c9',
}, },
}; };
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 824); assert.equal(witnesses.length, 838);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-CE2JQAR2.css']); assert.deepEqual(assets, ['index-BoF9kamK.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 128371); assert.equal(built.byteLength, 131216);
assert.equal(sha256(built), '0254a99e188554e0a2ae34dc9ac60109d86f9f1b6c2c96d2f8dcadb6641afe6c'); assert.equal(sha256(built), '1cab733ed1ae385a808f2afe5666ecc2252b48808d69a20c85e148c5eb60b96f');
}); });
+2 -2
View File
@@ -479,7 +479,7 @@ export function readStyleWitnesses(root) {
const OBSERVED_PROPERTIES = new Set(` const OBSERVED_PROPERTIES = new Set(`
--client-accent --client-accent-soft --client-bg --client-border --client-control --client-accent --client-accent-soft --client-bg --client-border --client-control
--client-delete-strike-y --client-device-chart-height --client-device-copy-color --client-delete-strike-y --client-device-chart-height --client-device-copy-color --client-rule-drag-y
--client-muted --client-panel --client-power-top --client-text --client-work-height --client-muted --client-panel --client-power-top --client-text --client-work-height
--harbor-connect --harbor-gateway --harbor-word -webkit-backdrop-filter --harbor-connect --harbor-gateway --harbor-word -webkit-backdrop-filter
-webkit-text-fill-color align-content align-items align-self animation -webkit-text-fill-color align-content align-items align-self animation
@@ -497,7 +497,7 @@ padding-bottom padding-inline padding-left padding-right padding-top place-conte
place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
text-decoration text-overflow text-shadow text-transform text-underline-offset top text-decoration text-overflow text-shadow text-transform text-underline-offset top
transform transform-box transform-origin transition transition-delay user-select touch-action transform transform-box transform-origin transition transition-delay user-select
vector-effect vertical-align visibility white-space width will-change z-index vector-effect vertical-align visibility white-space width will-change z-index
`.trim().split(/\s+/)); `.trim().split(/\s+/));
+1
View File
@@ -22,6 +22,7 @@
"src/web/features/servers/serverPickerModel.ts", "src/web/features/servers/serverPickerModel.ts",
"src/web/features/instructions/instructionBlocks.ts", "src/web/features/instructions/instructionBlocks.ts",
"src/web/features/instructions/prometheus.ts", "src/web/features/instructions/prometheus.ts",
"src/web/features/routing/ruleReorderModel.ts",
"monitoring/grafana/harbor-gateway.json" "monitoring/grafana/harbor-gateway.json"
], ],
"exclude": [".test-dist", "dist", "node_modules"] "exclude": [".test-dist", "dist", "node_modules"]