Add enabled local routing rules and gateway version reporting
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-11 22:14:43 +03:00
parent a0c66edb02
commit 1304a22f1f
22 changed files with 585 additions and 188 deletions

View File

@@ -27,12 +27,14 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
- Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement.
- Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame.
- Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion.
- Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable.
- Keep tooltips outside transformed, rotating, glowing, or filtered controls. Show them quickly above the control as independent translucent cloud surfaces.
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete.
## Acceptance pass

View File

@@ -69,6 +69,14 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri
- Disable pointer interaction during exit.
- Do not replay on ping updates or unrelated renders.
## Dynamic editors
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
- Give removal its own exit state and keep the row mounted until `animationend`; under reduced motion, remove it immediately.
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.
## Subscription input
- Show the public domain while retaining the full URL internally.

View File

@@ -50,6 +50,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
- Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control.
- Use inline confirmation for destructive subscription removal. Explain that VPN stops and proxy-dependent applications lose connectivity until a new subscription is configured.
- Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed.
- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy.
## Data presentation

View File

@@ -111,7 +111,7 @@ jobs:
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
UPDATE_DATAPLANE=false
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|dataplaneClient|gatewayRouting|singboxRuntime)\.js)$'; then
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/shared/errors\.js)$'; then
UPDATE_DATAPLANE=true
fi

View File

@@ -123,7 +123,7 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \
## Локальные правила маршрутизации
После добавления подписки откройте «Локальные правила» справа от основного экрана. Встроенное правило `*.ru` всегда отправляет российские домены напрямую. Дополнительно можно добавить точный домен, suffix домена или фрагмент имени; эти правила также обходят VPN, а остальной трафик идёт через выбранный сервер.
После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер.
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
@@ -221,7 +221,7 @@ cd ~/.vpn-proxy-client
### Версии
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает одну строку `M`, Gateway — строки `C` (client) и `B` (backend). Наведите курсор или переведите клавиатурный фокус на любую цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у backend там же указана фактическая версия `sing-box` из dataplane.
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
@@ -304,7 +304,7 @@ docker compose -f docker-compose.client.yml config
| `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` |
| `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent |
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Обычный деплой пересоздаёт только `vpn-proxy-control`; процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`. Dataplane обновляется отдельно, только когда изменены его runtime-файлы.
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`.
## Хранение данных

View File

@@ -1,6 +1,6 @@
# Harbor state recovery
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 2`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 adds locally managed domain routing rules; an absent field is migrated to an empty custom list while the built-in `.ru` rule remains in code.
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 3`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 adds the `enabled` state and migrates the former code-owned `.ru` exception into the first ordinary enabled rule.
## Atomic writes
@@ -8,13 +8,13 @@ Persistent files are written to a unique temporary file in the same directory, f
## Migration
On startup, a legacy `state.json` without `schemaVersion`, or a v1 state without local routing rules, is normalized and migrated to the current schema. Before replacement Harbor saves the original beside it:
On startup, a legacy `state.json` without `schemaVersion`, or any v1/v2 state, is normalized and migrated to the current schema. Existing custom rules are preserved, default to `enabled: true`, and follow the new ordinary `.ru` rule. A v3 state may keep, disable, or delete that rule without Harbor recreating it. Before replacement Harbor saves the original beside it:
```text
state.json.backup-v0-2026-07-11T12-00-00-000Z
```
The v1 migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. Older Harbor builds ignore the additional `schemaVersion` field, but the backup is the safest rollback source.
The migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. The backup is the safest rollback source because builds that only understand schema v2 do not know the per-rule `enabled` state.
## Corrupt JSON
@@ -24,7 +24,7 @@ If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
state.json.corrupt-2026-07-11T12-00-00-000Z
```
It then creates a valid empty v1 state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs.
It then creates a valid empty current-schema state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs.
Recovery should be performed while Harbor is stopped:

View File

@@ -32,7 +32,7 @@ import {
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { buildVersionInfo } from './version.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -90,7 +90,7 @@ let revision = normalizeStoredState(initialStoredState).revision;
function updateStoredState(update) {
return stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const next = normalizeStoredState(update(current));
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return next;
@@ -505,11 +505,7 @@ async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/version') {
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
const runtime = await singboxRuntime.refresh();
return sendJson(res, 200, {
...versionInfo,
components: { gatewayBackend: runtime.gatewayBackendVersion || null },
runtime: { singBox: runtime.singBoxVersion || null },
});
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {

View File

@@ -2,8 +2,9 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 2;
export const STATE_SCHEMA_VERSION = 3;
const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
@@ -55,8 +56,11 @@ export function migrateStoredState(value) {
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
return {
...normalizeStoredState(stored),
...normalizeStoredState({ ...stored, routeRules }),
schemaVersion: STATE_SCHEMA_VERSION,
};
}

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../shared/routingRules.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
@@ -52,10 +52,9 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
set_system_proxy: false,
},
];
const directRules = [...BUILT_IN_DIRECT_RULES, ...normalizeRouteRules(routeRules)].map((rule) => ({
[rule.type]: [rule.value],
outbound: 'direct',
}));
const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [

View File

@@ -18,3 +18,13 @@ export function buildVersionInfo(appMode, run = spawnSync) {
runtime: { singBox: detectSingBoxVersion(run) },
};
}
export function buildGatewayVersionInfo(controlInfo, dataplaneState) {
return {
...controlInfo,
runtime: {
dataplaneVersion: dataplaneState?.gatewayBackendVersion || null,
singBox: dataplaneState?.singBoxVersion || null,
},
};
}

View File

@@ -1,4 +1,4 @@
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../routingRules.js';
import { normalizeRouteRules } from '../routingRules.js';
const MODES = new Set(['client', 'gateway']);
const CONNECTION_STATES = new Set(['running', 'stopped']);
@@ -83,10 +83,7 @@ export function createStateSnapshot({
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
lastVerifiedAt: null,
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
localRules: {
builtIn: BUILT_IN_DIRECT_RULES.map((rule) => ({ ...rule })),
custom: stored.routeRules,
},
localRules: stored.routeRules,
},
operation: {
kind: nullableText(operation.kind),
@@ -147,7 +144,8 @@ export function assertStateSnapshot(snapshot) {
rule &&
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' &&
Boolean(rule.value)
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
if (
@@ -177,11 +175,8 @@ export function assertStateSnapshot(snapshot) {
!nullableString(snapshot.route.gatewayAddress) ||
!nullableDate(snapshot.route.lastVerifiedAt) ||
typeof snapshot.route.reason !== 'string' ||
!snapshot.route.localRules ||
!Array.isArray(snapshot.route.localRules.builtIn) ||
!snapshot.route.localRules.builtIn.every(validRouteRule) ||
!Array.isArray(snapshot.route.localRules.custom) ||
!snapshot.route.localRules.custom.every(validRouteRule) ||
!Array.isArray(snapshot.route.localRules) ||
!snapshot.route.localRules.every(validRouteRule) ||
!snapshot.operation ||
!nullableString(snapshot.operation.kind) ||
!OPERATION_STATES.has(snapshot.operation.status) ||

View File

@@ -1,9 +1,9 @@
export const BUILT_IN_DIRECT_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru' }),
export const INITIAL_ROUTE_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }),
]);
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
const MAX_RULES = 200;
export const MAX_ROUTE_RULES = 200;
function hostname(value) {
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
@@ -17,13 +17,16 @@ function hostname(value) {
function normalizeRule(rule) {
const type = String(rule?.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state');
}
const value = type === 'domain_keyword'
? String(rule?.value || '').trim().toLowerCase()
: hostname(rule?.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule value');
}
return { type, value };
return { type, value, enabled: rule?.enabled !== false };
}
export function normalizeRouteRules(value, { strict = false } = {}) {
@@ -31,13 +34,13 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
if (strict) throw new TypeError('Route rules must be an array');
return [];
}
if (strict && value.length > MAX_RULES) throw new TypeError(`Route rules limit is ${MAX_RULES}`);
if (strict && value.length > MAX_ROUTE_RULES) {
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
}
const seen = new Set(BUILT_IN_DIRECT_RULES.map(({ type, value: builtInValue }) => (
`${type}:${builtInValue}`
)));
const seen = new Set();
const normalized = [];
for (const candidate of value.slice(0, MAX_RULES)) {
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try {
const rule = normalizeRule(candidate);
const key = `${rule.type}:${rule.value}`;
@@ -50,3 +53,9 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
}
return normalized;
}
export function canAppendRouteRule(rules) {
return Array.isArray(rules) &&
rules.length < MAX_ROUTE_RULES &&
rules.every((rule) => String(rule?.value || '').trim());
}

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.3.0',
gatewayClient: '0.3.0',
gatewayBackend: '0.3.0',
macClient: '0.4.0',
gatewayClient: '0.4.0',
gatewayBackend: '0.4.0',
});
export function parseVersion(value) {

View File

@@ -13,6 +13,7 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
parseVersion,
@@ -32,7 +33,7 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'],
];
function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) {
function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
@@ -63,7 +64,7 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{singBox && <small>Runtime: sing-box {singBox}</small>}
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
@@ -83,12 +84,13 @@ function VersionDisplay({ isGateway, versionInfo }) {
component="Mac client"
componentKey="macClient"
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
singBox={runtimeSingBox}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
@@ -97,19 +99,25 @@ function VersionDisplay({ isGateway, versionInfo }) {
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway backend"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
singBox={runtimeSingBox}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
@@ -208,6 +216,106 @@ const ROUTE_RULE_PLACEHOLDERS = {
domain_keyword: 'cdn',
};
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule) => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const triggerRef = useRef(null);
const optionRefs = useRef([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (rootRef.current?.contains(event.target)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event, offset) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
function LocalRulesPanel({
open,
rules,
@@ -218,9 +326,14 @@ function LocalRulesPanel({
onAdd,
onChange,
onRemove,
onRemoveComplete,
onClose,
onSave,
}) {
const activeRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(activeRules) && !blocked;
const incomplete = activeRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={panelRef}
@@ -238,29 +351,41 @@ function LocalRulesPanel({
</header>
<form className="client-local-rules-form" onSubmit={onSave}>
<section className="client-local-rules-group" aria-labelledby="built-in-rules-title">
<span id="built-in-rules-title">Базовое правило</span>
<div className="client-local-rule is-built-in">
<strong>Суффикс</strong>
<code>*.ru</code>
<small>Всегда напрямую</small>
</div>
</section>
<section className="client-local-rules-group" aria-labelledby="custom-rules-title">
<span id="custom-rules-title">Ваши правила</span>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{rules.map((rule, index) => (
<div className="client-local-rule" key={index}>
<select
aria-label={`Тип правила ${index + 1}`}
value={rule.type}
onChange={(event) => onChange(index, 'type', event.target.value)}
<div
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ '--rule-index': index }}
inert={rule.removing ? true : undefined}
onAnimationEnd={(event) => {
if (event.animationName === 'client-local-rule-leave') {
onRemoveComplete(rule._key);
}
}}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => onChange(index, 'enabled', !rule.enabled)}
>
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</select>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => onChange(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
@@ -281,9 +406,14 @@ function LocalRulesPanel({
</button>
</div>
))}
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={onAdd}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
</section>
<p className="client-local-rules-note">
@@ -776,7 +906,7 @@ export function ClientOverviewPage({
function openLocalRules() {
setInstructionsOpen(false);
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
setLocalRulesRevision(state?.revision || 0);
setLocalRulesOpen(true);
}
@@ -789,10 +919,23 @@ export function ClientOverviewPage({
async function saveLocalRules(event) {
event.preventDefault();
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
const rules = localRulesDraft
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
setLocalRulesOpen(false);
}
function removeLocalRule(ruleKey) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
return;
}
setLocalRulesDraft((rules) => rules.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
@@ -1211,13 +1354,19 @@ export function ClientOverviewPage({
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
blocked={routeRulesBlocked}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
onAdd={() => setLocalRulesDraft((rules) => [
...rules,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
])}
onChange={changeLocalRule}
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
rules.filter((rule) => rule._key !== ruleKey)
))}
onClose={() => setLocalRulesOpen(false)}
onSave={saveLocalRules}
/>}

View File

@@ -800,7 +800,6 @@ p {
overflow-y: auto;
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
color: var(--client-text);
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
opacity: 0;
visibility: hidden;
transform: translateX(104%);
@@ -862,100 +861,242 @@ p {
}
.client-local-rules-list {
gap: 8px;
gap: 2px;
}
.client-local-rule {
min-width: 0;
display: grid;
grid-template-columns: 112px minmax(0, 1fr) 32px;
grid-template-columns: 24px 116px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 13px;
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
box-shadow: 0 10px 28px oklch(0.1 0.015 145 / 0.055);
transition: background 240ms ease, box-shadow 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
gap: 10px;
padding: 7px 0;
animation: client-local-rule-enter 560ms calc(var(--rule-index) * 55ms) cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 260ms ease, filter 360ms ease;
}
.client-local-rule:focus-within {
background: color-mix(in oklch, var(--client-panel) 74%, var(--client-bg));
box-shadow: 0 14px 34px oklch(0.1 0.015 145 / 0.09);
transform: translateY(-1px);
.client-local-rule.is-disabled {
opacity: 0.42;
filter: saturate(0);
}
.client-local-rule.is-built-in {
grid-template-columns: 92px minmax(0, 1fr) auto;
color: var(--client-muted);
.client-local-rule.is-removing {
pointer-events: none;
animation: client-local-rule-leave 360ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.client-local-rule.is-built-in strong {
font-size: 10px;
font-weight: 700;
}
.client-local-rule.is-built-in code {
color: var(--client-accent);
font: 700 13px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-local-rule.is-built-in small {
font-size: 8px;
white-space: nowrap;
}
.client-local-rule select,
.client-local-rule input {
min-width: 0;
height: 36px;
.client-local-rule-enabled {
width: 24px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
outline: 0;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
color: var(--client-text);
font-size: 10px;
}
.client-local-rule select {
padding: 0 7px;
background: transparent;
color: var(--client-muted);
cursor: pointer;
}
.client-local-rule-enabled svg {
width: 18px;
height: 18px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
transition: color 260ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled circle {
stroke-width: 1.4;
transition: fill 320ms ease, stroke 260ms ease;
}
.client-local-rule-enabled path {
stroke-width: 1.8;
stroke-dasharray: 12;
stroke-dashoffset: 12;
transition: stroke-dashoffset 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled[aria-checked='true'] {
color: var(--client-accent);
}
.client-local-rule-enabled[aria-checked='true'] svg {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 48%, transparent));
transform: scale(1.08);
}
.client-local-rule-enabled[aria-checked='true'] circle {
fill: color-mix(in oklch, var(--client-accent) 12%, transparent);
}
.client-local-rule-enabled[aria-checked='true'] path {
stroke-dashoffset: 0;
}
.client-rule-type {
position: relative;
min-width: 0;
z-index: 1;
}
.client-rule-type.is-open {
z-index: 4;
}
.client-rule-type-trigger {
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
cursor: pointer;
transition: color 220ms ease, text-shadow 300ms ease;
}
.client-rule-type-trigger svg {
width: 9px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-trigger {
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 44%, transparent);
}
.client-rule-type.is-open .client-rule-type-trigger svg {
transform: rotate(180deg);
}
.client-rule-type-list {
position: absolute;
top: calc(100% - 1px);
left: -8px;
z-index: 3;
width: max-content;
min-width: calc(100% + 16px);
display: grid;
gap: 1px;
padding: 7px 8px;
background: color-mix(in oklch, var(--client-bg) 91%, transparent);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
opacity: 0;
visibility: hidden;
filter: blur(8px);
transform: translateY(-8px) scale(0.97);
transform-origin: top left;
pointer-events: none;
transition: opacity 200ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 420ms;
}
.client-rule-type.is-open .client-rule-type-list {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0) scale(1);
pointer-events: auto;
transition-delay: 0s;
}
.client-rule-type-list button {
padding: 7px 6px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
white-space: nowrap;
cursor: pointer;
opacity: 0;
filter: blur(5px);
transform: translateY(-5px);
transition: color 180ms ease, opacity 260ms ease, filter 340ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-list button {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(2) {
transition-delay: 35ms;
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(3) {
transition-delay: 70ms;
}
.client-rule-type-list button:hover,
.client-rule-type-list button:focus-visible,
.client-rule-type-list button[aria-selected='true'] {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 40%, transparent);
}
.client-local-rule input {
padding: 0 10px;
min-width: 0;
height: 34px;
padding: 0 2px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
box-shadow: 0 1px 0 transparent;
transition: color 220ms ease, box-shadow 300ms ease, text-shadow 300ms ease;
}
.client-local-rule input::placeholder {
color: color-mix(in oklch, var(--client-muted) 72%, transparent);
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
}
.client-local-rule select:focus-visible,
.client-local-rule input:focus-visible,
.client-local-rule button:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
.client-local-rule input:focus {
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-local-rule > button {
width: 32px;
.client-local-rule > button:last-child {
width: 28px;
height: 32px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--client-muted);
font-size: 17px;
cursor: pointer;
transition: color 180ms ease, background 180ms ease, transform 220ms ease;
transition: color 180ms ease, filter 260ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule > button:hover {
background: color-mix(in oklch, oklch(0.68 0.15 28) 10%, transparent);
.client-local-rule > button:last-child:hover {
color: oklch(0.68 0.15 28);
transform: scale(1.08);
filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42));
transform: rotate(8deg) scale(1.1);
}
.client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible,
.client-local-rule > button:last-child:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 48%, transparent);
}
.client-local-rules-empty {
@@ -974,16 +1115,43 @@ p {
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: opacity 220ms ease, color 220ms ease, text-shadow 300ms ease;
}
.client-local-rule-add:hover {
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 55%, transparent);
}
.client-local-rule-add:disabled {
opacity: 0.3;
cursor: not-allowed;
text-shadow: none;
}
.client-local-rule-add-slot {
min-height: 40px;
display: flex;
align-items: center;
gap: 12px;
}
.client-local-rule-add-slot > span {
color: var(--client-muted);
font-size: 8px;
opacity: 0;
filter: blur(5px);
transform: translateX(-6px);
transition: opacity 220ms ease, filter 320ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-add-slot > span.is-visible {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
.client-local-rules-note {
padding: 12px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-control) 44%, transparent);
max-width: 48ch;
color: var(--client-muted);
font-size: 9px;
line-height: 1.65;
@@ -1002,24 +1170,23 @@ p {
}
.client-local-rules-actions button {
padding: 10px 14px;
padding: 8px 0 8px 14px;
border: 0;
border-radius: 9px;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
background: transparent;
color: var(--client-text);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 200ms ease, color 200ms ease, transform 220ms ease;
transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-actions button:hover:not(:disabled) {
background: var(--client-control);
color: var(--client-accent);
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 40%, transparent));
transform: translateY(-1px);
}
.client-local-rules-actions .is-primary {
background: color-mix(in oklch, var(--client-accent) 18%, var(--client-control));
color: var(--client-accent);
}
@@ -1028,6 +1195,16 @@ p {
cursor: wait;
}
@keyframes client-local-rule-enter {
from { opacity: 0; filter: blur(8px); transform: translateY(-10px) scale(0.985); }
to { opacity: 1; filter: blur(0); transform: translateY(0) scale(1); }
}
@keyframes client-local-rule-leave {
from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); }
to { opacity: 0; filter: blur(8px); transform: translateX(18px) scale(0.97); }
}
.client-instructions {
position: fixed;
inset: 0 auto 0 0;
@@ -2524,25 +2701,29 @@ p {
font-size: 18px;
}
.client-local-rule,
.client-local-rule.is-built-in {
grid-template-columns: 1fr 32px;
.client-local-rule {
grid-template-columns: 24px minmax(0, 1fr) 28px;
gap: 8px;
}
.client-local-rule select,
.client-local-rule.is-built-in strong {
grid-column: 1 / -1;
}
.client-local-rule input,
.client-local-rule.is-built-in code {
.client-local-rule-enabled {
grid-column: 1;
grid-row: 1;
}
.client-local-rule.is-built-in small {
.client-rule-type {
grid-column: 2;
grid-row: 1;
}
.client-local-rule > button:last-child {
grid-column: 3;
grid-row: 1;
}
.client-local-rule input {
grid-column: 2 / -1;
grid-row: 2;
white-space: normal;
}
.client-instructions-header h2 {
@@ -2609,7 +2790,17 @@ p {
.client-local-rules-toggle svg,
.client-local-rules-toggle span,
.client-local-rule,
.client-local-rule > button,
.client-local-rule-enabled svg,
.client-local-rule-enabled circle,
.client-local-rule-enabled path,
.client-rule-type-trigger,
.client-rule-type-trigger svg,
.client-rule-type-list,
.client-rule-type-list button,
.client-local-rule input,
.client-local-rule > button:last-child,
.client-local-rule-add,
.client-local-rule-add-slot > span,
.client-local-rules-actions button {
transition: none;
animation: none;

View File

@@ -16,6 +16,9 @@ test('gateway deploy updates control without recreating dataplane', () => {
assert.match(compose, /DATAPLANE_SOCKET: \/run\/vpn-proxy\/dataplane\.sock/);
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singboxRuntime\|version\)/);
assert.match(workflow, /src\/shared\/errors/);
assert.doesNotMatch(workflow, /dataplaneClient/);
});
test('runtime images include shared server modules', () => {

View File

@@ -24,8 +24,9 @@ const subscriptionConfig = {
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_keyword', value: 'cdn' },
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: false },
],
});
@@ -34,7 +35,6 @@ test('client exposes one local proxy and routes local exceptions before the sele
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ domain_keyword: ['cdn'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
@@ -42,7 +42,10 @@ test('client exposes one local proxy and routes local exceptions before the sele
});
test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true });
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
clientDirect: true,
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },

View File

@@ -22,7 +22,9 @@ const subscriptionConfig = {
};
test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [

View File

@@ -169,10 +169,9 @@ setInterval(() => {}, 60_000);
});
assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, 'test-vpn');
assert.deepEqual(initial.route.localRules, {
builtIn: [{ type: 'domain_suffix', value: 'ru' }],
custom: [],
});
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const stateKeys = Object.keys(initial).sort();
let revision = initial.revision;
@@ -242,18 +241,20 @@ setInterval(() => {}, 60_000);
const routed = await mutation('/api/route-rules', 'PUT', {
expectedRevision: rulesRevision,
rules: [
{ type: 'domain', value: 'https://Example.com/private?q=1' },
{ type: 'domain_suffix', value: '*.Example.org' },
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
],
});
assert.deepEqual(routed.state.route.localRules.custom, [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: 'example.org' },
assert.deepEqual(routed.state.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
]);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
@@ -270,7 +271,7 @@ setInterval(() => {}, 60_000);
});
assert.equal(staleRules.response.status, 409);
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
assert.deepEqual((await request(port, '/api/state')).route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
@@ -291,7 +292,7 @@ setInterval(() => {}, 60_000);
assert.equal(failedRules.response.status, 422);
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
const rolledBack = await request(port, '/api/state');
assert.deepEqual(rolledBack.route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual(rolledBack.route.localRules, routed.state.route.localRules);
assert.equal(rolledBack.connection.process, 'running');
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
revision = rolledBack.revision;
@@ -317,7 +318,7 @@ if (process.argv[2] === 'check') {
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules.custom, routed.state.route.localRules.custom);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');

View File

@@ -33,10 +33,10 @@ test('a failure before rename preserves the last successful file', (t) => {
);
});
test('schema v1 state migrates to the current schema and keeps a backup', (t) => {
test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 1,
schemaVersion: 2,
revision: 7,
selectedTag: 'nl',
servers: [{ tag: 'nl' }],
@@ -49,8 +49,11 @@ test('schema v1 state migrates to the current schema and keeps a backup', (t) =>
const migrated = store.read();
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(store.migration.fromVersion, 1);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVersionInfo } from '../../src/server/version.js';
import { buildGatewayVersionInfo, buildVersionInfo } from '../../src/server/version.js';
import { HARBOR_VERSIONS, versionCompatibility } from '../../src/shared/versions.js';
test('component versions enforce one Harbor major and one Gateway major.minor', () => {
@@ -22,6 +22,24 @@ test('component versions enforce one Harbor major and one Gateway major.minor',
}).compatible, false);
});
test('gateway reports control backend and deployed dataplane separately', () => {
const control = {
apiVersion: 1,
location: 'gateway',
components: { gatewayBackend: '0.4.0' },
runtime: { singBox: '1.12.13' },
};
assert.deepEqual(buildGatewayVersionInfo(control, {
gatewayBackendVersion: '0.3.0',
singBoxVersion: '1.12.13',
}), {
apiVersion: 1,
location: 'gateway',
components: { gatewayBackend: '0.4.0' },
runtime: { dataplaneVersion: '0.3.0', singBox: '1.12.13' },
});
});
test('runtime version info reports the installed sing-box binary', () => {
const run = () => ({ stdout: 'sing-box version 1.12.13\n', stderr: '' });
assert.deepEqual(buildVersionInfo('gateway', run), {

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeRouteRules } from '../../src/shared/routingRules.js';
import { canAppendRouteRule, normalizeRouteRules } from '../../src/shared/routingRules.js';
test('local route rules normalize URLs, suffixes and duplicates', () => {
assert.deepEqual(normalizeRouteRules([
@@ -9,12 +9,15 @@ test('local route rules normalize URLs, suffixes and duplicates', () => {
{ type: 'domain_suffix', value: '*.Example.org' },
{ type: 'domain_keyword', value: ' CDN ' },
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: '.ru' },
{ type: 'domain_suffix', value: '.ru', enabled: false },
], { strict: true }), [
{ type: 'domain', value: 'example.com' },
{ type: 'domain_suffix', value: 'example.org' },
{ type: 'domain_keyword', value: 'cdn' },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: true },
{ type: 'domain_suffix', value: 'ru', enabled: false },
]);
assert.equal(canAppendRouteRule([{ value: 'filled' }]), true);
assert.equal(canAppendRouteRule([{ value: '' }]), false);
});
test('invalid local route rules fail at the strict boundary', () => {