diff --git a/README.md b/README.md
index 2b85547..07b18f9 100644
--- a/README.md
+++ b/README.md
@@ -279,10 +279,27 @@ docker compose -f docker-compose.gateway.yml logs --tail=100
```bash
docker compose -f docker-compose.gateway.yml config
docker compose -f docker-compose.client.yml config
+docker compose -f docker-compose.client.local.yml config
```
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
+### Локальное тестирование Harbor Connect
+
+Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
+
+```bash
+docker compose -f docker-compose.client.local.yml up -d --build
+```
+
+Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
+
+```bash
+docker compose -f docker-compose.client.local.yml down -v
+```
+
+Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
+
## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
diff --git a/docker-compose.client.local.yml b/docker-compose.client.local.yml
new file mode 100644
index 0000000..e42b570
--- /dev/null
+++ b/docker-compose.client.local.yml
@@ -0,0 +1,24 @@
+name: harbor-connect-local
+
+services:
+ harbor-connect:
+ extends:
+ file: docker-compose.client.yml
+ service: harbor-connect
+ container_name: harbor-connect-local
+ environment:
+ PORT: ${LOCAL_CLIENT_UI_PORT:-3457}
+ PROXY_PORT: ${LOCAL_CLIENT_PROXY_PORT:-8083}
+ ports: !override
+ - "127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}:${LOCAL_CLIENT_UI_PORT:-3457}"
+ - "127.0.0.1:${LOCAL_CLIENT_PROXY_PORT:-8083}:${LOCAL_CLIENT_PROXY_PORT:-8083}"
+ volumes: !override
+ - vpn-proxy-client-local-data:/var/lib/vpn-proxy
+ - sing-box-client-local-cache:/var/lib/sing-box
+ - ./.runtime:/run/harbor-host:ro
+ healthcheck:
+ test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}/api/state"]
+
+volumes:
+ vpn-proxy-client-local-data:
+ sing-box-client-local-cache:
diff --git a/scripts/harbor-version.mjs b/scripts/harbor-version.mjs
index 6403636..34ef0c2 100644
--- a/scripts/harbor-version.mjs
+++ b/scripts/harbor-version.mjs
@@ -34,7 +34,7 @@ export function affectedComponents(files) {
else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
add('macClient', 'gatewayClient');
} else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend');
- else if (/^(Dockerfile\.client|docker-compose\.client\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
+ else if (/^(Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
add('macClient');
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
add('gatewayBackend');
diff --git a/src/server/index.js b/src/server/index.js
index 11346aa..4919b15 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -43,6 +43,11 @@ import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
+const TERMINAL_SUBSCRIPTION_CODES = new Set([
+ 'SUBSCRIPTION_EXPIRED',
+ 'SUBSCRIPTION_DISABLED',
+ 'SUBSCRIPTION_REJECTED',
+]);
fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -215,6 +220,16 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
+function resetSavedSubscription({ stopRuntime = true } = {}) {
+ return serializeControl(async () => {
+ if (stopRuntime) await stopSingbox();
+ removeSingboxConfig();
+ subscriptionCacheStore.remove();
+ updateStoredState((state) => ({ routeRules: state.routeRules }));
+ gatewayAutoState = createGatewayAutoState();
+ });
+}
+
async function publicState() {
const runtime = await singboxRuntime.refresh();
const state = normalizeStoredState(stateStore.read());
@@ -529,10 +544,17 @@ function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
- const subscriptionUrl = stateStore.read().subscriptionUrl;
- if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
- const parsed = await fetchSubscription(subscriptionUrl);
- return commitSubscription(subscriptionUrl, parsed);
+ try {
+ const subscriptionUrl = stateStore.read().subscriptionUrl;
+ if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
+ const parsed = await fetchSubscription(subscriptionUrl);
+ return await commitSubscription(subscriptionUrl, parsed);
+ } catch (error) {
+ if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
+ await resetSavedSubscription();
+ }
+ throw error;
+ }
})().finally(() => {
subscriptionRefreshPromise = null;
});
@@ -654,13 +676,7 @@ async function handleApi(req, res) {
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
- await withOperation('subscription-forget', () => serializeControl(async () => {
- await stopSingbox();
- removeSingboxConfig();
- subscriptionCacheStore.remove();
- updateStoredState((state) => ({ routeRules: state.routeRules }));
- gatewayAutoState = createGatewayAutoState();
- }));
+ await withOperation('subscription-forget', () => resetSavedSubscription());
return sendState(res);
}
@@ -755,7 +771,13 @@ process.on('SIGINT', shutdown);
await refreshGatewayAutoMode({ reconfigure: false })
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
- writeCurrentConfig();
+ try {
+ writeCurrentConfig();
+ } catch (error) {
+ if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
+ console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
+ await resetSavedSubscription({ stopRuntime: false });
+ }
}
await startSingbox()
.then(() => {
diff --git a/src/server/subscription.js b/src/server/subscription.js
index 17b9ccc..1ba078c 100644
--- a/src/server/subscription.js
+++ b/src/server/subscription.js
@@ -10,6 +10,23 @@ import {
import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
+const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
+
+function usableProxyOutbound(outbound) {
+ const host = String(outbound?.server || '').trim().toLowerCase();
+ const port = Number(outbound?.server_port);
+ return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
+}
+
+function rejectedSubscriptionCode(outbounds) {
+ const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
+ if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
+ if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
+ if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
+ return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
+ ? 'SUBSCRIPTION_REJECTED'
+ : 'SUBSCRIPTION_INVALID';
+}
export function getHwid() {
fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -114,9 +131,18 @@ export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = [];
+ const rejectedOutbounds = [];
const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => {
+ if (!outbound || typeof outbound !== 'object') {
+ rejectedOutbounds.push(outbound);
+ return [];
+ }
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
+ if (!usableProxyOutbound(outbound)) {
+ rejectedOutbounds.push(outbound);
+ return [];
+ }
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return [];
@@ -125,7 +151,7 @@ export function normalizeSubscriptionConfig(value) {
return [{ ...outbound, tag: id }];
});
- if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID');
+ if (!servers.length) throw new HarborError(rejectedSubscriptionCode(rejectedOutbounds));
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
}
@@ -198,6 +224,12 @@ export async function fetchSubscription(url, options) {
const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
+ if (userInfo.expire > 0 && userInfo.expire * 1000 <= Date.now()) {
+ throw new HarborError('SUBSCRIPTION_EXPIRED');
+ }
+ if (userInfo.total > 0 && (userInfo.upload || 0) + (userInfo.download || 0) >= userInfo.total) {
+ throw new HarborError('SUBSCRIPTION_TRAFFIC_EXHAUSTED');
+ }
const parsed = parseSubscriptionBody(body);
return {
diff --git a/src/shared/errors.js b/src/shared/errors.js
index 8f0aa81..fc1047d 100644
--- a/src/shared/errors.js
+++ b/src/shared/errors.js
@@ -3,6 +3,10 @@ export const ERROR_DEFINITIONS = Object.freeze({
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
+ SUBSCRIPTION_EXPIRED: { status: 400, message: 'Срок действия подписки истёк.', retryable: false },
+ SUBSCRIPTION_TRAFFIC_EXHAUSTED: { status: 400, message: 'Трафик по подписке закончился.', retryable: false },
+ SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
+ SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
diff --git a/src/shared/versions.js b/src/shared/versions.js
index 4be04d7..b57480a 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.7.23',
- gatewayClient: '0.7.23',
- gatewayBackend: '0.7.1',
+ macClient: '0.7.36',
+ gatewayClient: '0.7.34',
+ gatewayBackend: '0.7.5',
});
export function parseVersion(value) {
diff --git a/src/web/api.js b/src/web/api.js
index 16b57a2..baeed36 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -50,6 +50,11 @@ export const api = {
state: () => request('/api/state'),
version: () => request('/api/version'),
subscription: {
+ validate: (url, { signal } = {}) => request('/api/subscription/validate', {
+ method: 'POST',
+ body: JSON.stringify({ url }),
+ signal,
+ }),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 7dbcb92..2e5d98e 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -17,6 +17,7 @@ import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { ServerPicker } from './ServerPicker.jsx';
+import { ERROR_DEFINITIONS } from '../../shared/errors.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
@@ -134,10 +135,10 @@ function InlineError({ error, context }) {
return (
{error.message}
+ {error.retry && }
{error.correlationId && (
Код: {error.correlationId.slice(0, 8)}
)}
- {error.retry && }
);
}
@@ -234,10 +235,11 @@ const localRuleValues = (rules) => rules
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
-function localRuleStatus(rule, savedRules, activeRules) {
+function localRuleStatus(rule, savedRules, activeRules, runtimeActive) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
+ if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
@@ -340,6 +342,7 @@ function LocalRulesPanel({
rules,
savedRules,
activeRules,
+ runtimeActive,
blocked,
dirty,
restartPending,
@@ -399,7 +402,12 @@ function LocalRulesPanel({
Правила
{rules.map((rule, index) => {
- const [status, statusLabel] = localRuleStatus(rule, savedRules, activeRules);
+ const [status, statusLabel] = localRuleStatus(
+ rule,
+ savedRules,
+ activeRules,
+ runtimeActive,
+ );
return (
block.id !== openInstructionId)]
: instructionGuides;
const normalizedSubscriptionUrl = subscriptionUrl.trim();
+ const currentSubscriptionValidation = subscriptionValidation.url === normalizedSubscriptionUrl
+ ? subscriptionValidation
+ : null;
+ const localSubscriptionError = normalizedSubscriptionUrl
+ && !isSubscriptionUrlValid(normalizedSubscriptionUrl)
+ ? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
+ : null;
+ const subscriptionError = currentSubscriptionValidation?.error
+ || localSubscriptionError
+ || (error?.context === 'subscription' ? error : null);
const subscriptionValidationStatus = !normalizedSubscriptionUrl
? 'idle'
- : isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
+ : subscriptionError || !isSubscriptionUrlValid(normalizedSubscriptionUrl)
+ ? 'invalid'
+ : currentSubscriptionValidation?.status || 'checking';
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
@@ -682,9 +708,12 @@ export function ClientOverviewPage({
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
- const pendingLocalRulesCount = (state?.route?.localRules || []).filter((rule) => (
- rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
- )).length;
+ const localRulesPendingRestart = connected && state?.route?.localRulesPendingRestart === true;
+ const pendingLocalRulesCount = localRulesPendingRestart
+ ? (state?.route?.localRules || []).filter((rule) => (
+ rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
+ )).length
+ : 0;
useEffect(() => {
setNow(Date.now());
@@ -698,6 +727,36 @@ export function ClientOverviewPage({
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
+ useEffect(() => {
+ if (!normalizedSubscriptionUrl || !isSubscriptionUrlValid(normalizedSubscriptionUrl)) return undefined;
+ const controller = new AbortController();
+ setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking', error: null });
+ const timer = setTimeout(async () => {
+ try {
+ await api.subscription.validate(normalizedSubscriptionUrl, { signal: controller.signal });
+ setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid', error: null });
+ } catch (requestError) {
+ if (requestError?.name === 'AbortError') return;
+ setSubscriptionValidation({
+ url: normalizedSubscriptionUrl,
+ status: 'invalid',
+ error: {
+ context: 'subscription',
+ message: requestError.message,
+ correlationId: requestError.correlationId,
+ retry: requestError.retryable
+ ? () => setSubscriptionValidationAttempt((attempt) => attempt + 1)
+ : null,
+ },
+ });
+ }
+ }, 300);
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [normalizedSubscriptionUrl, subscriptionValidationAttempt]);
+
useEffect(() => {
if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200);
@@ -956,7 +1015,7 @@ export function ClientOverviewPage({
localRulesBaselineRef.current = JSON.stringify(rules);
setLocalRulesRevision(result.state.route.localRulesRevision);
setConfirmingLocalRulesClose(false);
- if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
+ if (!connected || !result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
}
function requestCloseLocalRules() {
@@ -1030,7 +1089,7 @@ export function ClientOverviewPage({
}
{hasSubscription && subscriptionContentReady && }
{showPower && (
@@ -1287,7 +1346,11 @@ export function ClientOverviewPage({
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
- onChange={(event) => setSubscriptionUrl(event.target.value)}
+ onChange={(event) => {
+ if (error?.context === 'subscription') onDismissError();
+ setSubscriptionValidation({ url: '', status: 'idle', error: null });
+ setSubscriptionUrl(event.target.value);
+ }}
onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl('');
@@ -1304,18 +1367,21 @@ export function ClientOverviewPage({
)}
-
+
@@ -1406,9 +1472,10 @@ export function ClientOverviewPage({
rules={localRulesDraft}
savedRules={state?.route?.localRules || []}
activeRules={state?.route?.activeLocalRules || []}
+ runtimeActive={connected}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty}
- restartPending={state?.route?.localRulesPendingRestart === true}
+ restartPending={localRulesPendingRestart}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
diff --git a/src/web/components/ServerPicker.jsx b/src/web/components/ServerPicker.jsx
index 2926e46..b96123f 100644
--- a/src/web/components/ServerPicker.jsx
+++ b/src/web/components/ServerPicker.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import {
autoServer,
@@ -115,7 +115,6 @@ export function ServerPicker({
const [collapsed, setCollapsed] = useState([]);
const [pings, setPings] = useState({});
const [checking, setChecking] = useState(false);
- const initialCheckStarted = useRef(false);
const serverKey = servers.map(({ id }) => id).join('|');
useEffect(() => {
@@ -189,12 +188,6 @@ export function ServerPicker({
}
}
- useEffect(() => {
- if (initialCheckStarted.current || !servers.length) return;
- initialCheckStarted.current = true;
- checkVisible();
- }, [serverKey]);
-
if (servers.length === 1) {
return
{prompt && Выберите сервер}
diff --git a/src/web/styles.css b/src/web/styles.css
index 98fbbee..aeff5f6 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -1930,6 +1930,12 @@ p {
place-items: center;
}
+.client-duration-unit {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.4em;
+}
+
.client-duration-unit .client-duration-part.is-value {
display: inline-block;
min-width: 2ch;
@@ -1941,13 +1947,8 @@ p {
text-align: left;
}
-.client-duration-unit[data-unit='days'] .is-label { width: 4ch; }
-.client-duration-unit[data-unit='hours'] .is-label { width: 5ch; }
-.client-duration-unit[data-unit='minutes'] .is-label { width: 6ch; }
-.client-duration-unit[data-unit='seconds'] .is-label { width: 7ch; }
-
-.client-duration-unit + .client-duration-unit::before {
- content: ' ';
+.client-duration-unit + .client-duration-unit {
+ margin-left: 0.9em;
}
.client-duration-seconds-value {
@@ -2637,7 +2638,7 @@ p {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
- width: min(100%, 210px);
+ width: min(100%, 150px);
margin-inline: auto;
}
@@ -3055,7 +3056,6 @@ p {
}
.client-servers.is-scalable .client-server-grid {
- width: 100%;
gap: 5px;
}
@@ -3274,6 +3274,36 @@ p {
top: calc(100% + 2px);
}
+.client-inline-error.is-subscription > span,
+.client-inline-error.is-subscription > button {
+ animation: client-subscription-error-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.client-inline-error.is-subscription small {
+ flex-basis: 100%;
+ font-size: 8px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ opacity: 0.45;
+ animation: client-subscription-code-in 560ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@keyframes client-subscription-error-in {
+ from {
+ opacity: 0;
+ filter: blur(7px);
+ transform: translateY(-3px);
+ }
+}
+
+@keyframes client-subscription-code-in {
+ from {
+ opacity: 0;
+ filter: blur(5px);
+ transform: translateY(-2px);
+ }
+}
+
.client-inline-error small {
color: var(--client-muted);
font-size: 9px;
@@ -3598,6 +3628,7 @@ p {
}
@media (prefers-reduced-motion: reduce) {
+ .client-inline-error.is-subscription > *,
.client-operation-progress::before,
.client-delete-strike,
.client-power,
diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js
index 64626f5..67cccb4 100644
--- a/test/server/state-contract.test.js
+++ b/test/server/state-contract.test.js
@@ -90,6 +90,60 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
);
});
+test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
+ const port = await freePort();
+ const subscriptionUrl = 'https://provider.example/disabled';
+ const rejectedServer = {
+ type: 'vless',
+ tag: '🚫 Subscription disabled',
+ server: '0.0.0.0',
+ server_port: 1,
+ };
+ const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
+ fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
+ subscriptionUrl,
+ selectedTag: rejectedServer.tag,
+ servers: [rejectedServer],
+ routeRules,
+ }));
+ fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
+ url: subscriptionUrl,
+ config: { outbounds: [rejectedServer] },
+ }));
+ fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
+
+ const child = spawn(process.execPath, ['src/server/index.js'], {
+ cwd: root,
+ env: {
+ ...process.env,
+ APP_MODE: 'client',
+ DATA_DIR: dir,
+ PORT: String(port),
+ HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
+ },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ t.after(async () => {
+ child.kill('SIGTERM');
+ if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ const state = await waitForState(port, child, () => stderr);
+ assert.equal(state.subscription.status, 'missing');
+ assert.equal(state.hasSubscription, false);
+ assert.deepEqual(state.servers, []);
+ assert.ok(state.route.localRules.some((rule) => (
+ rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
+ )));
+ assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
+ assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
+ assert.equal(child.exitCode, null);
+});
+
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
@@ -121,6 +175,7 @@ setInterval(() => {}, 60_000);
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
+ let trafficExhaustedNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
@@ -134,6 +189,42 @@ setInterval(() => {}, 60_000);
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
+ if (req.url === '/traffic' || req.url === trafficExhaustedNextPath) {
+ trafficExhaustedNextPath = '';
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'subscription-userinfo': 'upload=60; download=40; total=100; expire=4102444800',
+ });
+ return res.end(JSON.stringify({
+ outbounds: [{
+ type: 'vless',
+ tag: 'Account unavailable',
+ server: '0.0.0.0',
+ server_port: 1,
+ }],
+ }));
+ }
+ if (req.url === '/expired') {
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'subscription-userinfo': 'upload=10; download=20; total=100; expire=1',
+ });
+ return res.end(JSON.stringify(config));
+ }
+ if (req.url === '/disabled') {
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'subscription-userinfo': 'upload=0; download=0; total=100; expire=4102444800',
+ });
+ return res.end(JSON.stringify({
+ outbounds: [{
+ type: 'vless',
+ tag: '🚫 Subscription disabled',
+ server: '0.0.0.0',
+ server_port: 1,
+ }],
+ }));
+ }
if (req.url === invalidNextPath) {
invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' });
@@ -240,6 +331,9 @@ setInterval(() => {}, 60_000);
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
+ ['/expired', 'SUBSCRIPTION_EXPIRED'],
+ ['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
+ ['/disabled', 'SUBSCRIPTION_DISABLED'],
]) {
const failedImport = await rawRequest(
port,
@@ -262,6 +356,16 @@ setInterval(() => {}, 60_000);
);
}
+ trafficExhaustedNextPath = '/subscription/test';
+ const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
+ assert.equal(exhaustedRefresh.response.status, 400);
+ assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
+ const stateAfterExhaustedRefresh = await request(port, '/api/state');
+ assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
+ assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
+ assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
+ revision = stateAfterExhaustedRefresh.revision;
+
const missingServer = await rawRequest(
port,
'/api/apply',
diff --git a/test/server/subscription.test.js b/test/server/subscription.test.js
index 39e1973..58a7bff 100644
--- a/test/server/subscription.test.js
+++ b/test/server/subscription.test.js
@@ -54,3 +54,25 @@ test('removed selected server requires an explicit new choice', () => {
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
assert.equal(selectRefreshedServer('', before, after), '');
});
+
+test('provider placeholders never become selectable servers', () => {
+ const disabled = outbound('🚫 Subscription disabled', '0.0.0.0', 1);
+ const trafficExhausted = outbound('🚫 Traffic limit exceeded', '0.0.0.0', 1);
+
+ assert.throws(
+ () => parse([disabled]),
+ (error) => error.code === 'SUBSCRIPTION_DISABLED',
+ );
+ assert.throws(
+ () => parse([outbound('Account error', '::', 1)]),
+ (error) => error.code === 'SUBSCRIPTION_REJECTED',
+ );
+ assert.throws(
+ () => parse([trafficExhausted]),
+ (error) => error.code === 'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
+ );
+
+ const parsed = parse([disabled, outbound('Amsterdam', 'nl.example')]);
+ assert.deepEqual(parsed.servers.map((server) => server.label), ['Amsterdam']);
+ assert.equal(parsed.config.outbounds.length, 1);
+});
diff --git a/test/version-script.test.js b/test/version-script.test.js
index 1cd09b0..e4e7bac 100644
--- a/test/version-script.test.js
+++ b/test/version-script.test.js
@@ -12,6 +12,7 @@ const versions = {
test('version paths map to the components actually shipped by this repository', () => {
assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']);
assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']);
+ assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']);
assert.deepEqual(affectedComponents(['package-lock.json']), [
'macClient',
'gatewayClient',
diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js
index f0d8a5c..8e1bc86 100644
--- a/test/web/responsive-layout-contract.test.js
+++ b/test/web/responsive-layout-contract.test.js
@@ -5,6 +5,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
+const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
function rule(selector, source = styles) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -33,12 +34,15 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
const scroll = rule('.client-server-scroll');
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
+ const grid = rule('.client-server-grid');
assert.match(scroll, /overflow-y:\s*auto/);
assert.match(scroll, /scrollbar-width:\s*none/);
assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
assert.match(simpleScroll, /max-height:\s*none/);
assert.match(simpleScroll, /overflow:\s*visible/);
+ assert.match(grid, /width:\s*min\(100%, 150px\)/);
+ assert.doesNotMatch(rule('.client-servers.is-scalable .client-server-grid'), /width:/);
});
test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
@@ -92,3 +96,21 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
});
+
+test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
+ assert.match(component, /api\.subscription\.validate\(normalizedSubscriptionUrl/);
+ assert.match(component, /status: 'checking'/);
+ assert.match(component, /status: 'valid'/);
+ assert.match(component, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
+ assert.match(component, /subscriptionValidationStatus === 'checking' \? '…' : '×'/);
+ assert.match(component, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
+ assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
+ assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
+ assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
+ assert.match(styles, /@keyframes client-subscription-error-in[\s\S]*filter:\s*blur\(7px\)/);
+ assert.match(styles, /@keyframes client-subscription-code-in[\s\S]*transform:\s*translateY\(-2px\)/);
+ assert.match(
+ /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '',
+ /\.client-inline-error\.is-subscription > \*/,
+ );
+});
diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js
index 3f38aa4..e5d51b3 100644
--- a/test/web/rule-editor-contract.test.js
+++ b/test/web/rule-editor-contract.test.js
@@ -17,8 +17,11 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
assert.match(component, /activeLocalRules/);
assert.match(component, /localRulesRevision/);
assert.match(component, /Не сохранено/);
+ assert.match(component, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
assert.match(component, /Ждёт перезапуска/);
assert.match(component, /Перезапустить VPN/);
+ assert.match(component, /const localRulesPendingRestart = connected && state\?\.route\?\.localRulesPendingRestart === true/);
+ assert.match(component, /if \(!connected \|\| !result\.state\.route\.localRulesPendingRestart\) setLocalRulesOpen\(false\)/);
assert.match(component, /client-deletable-row/);
assert.match(component, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
assert.doesNotMatch(component, /client-rule-delete-cross/);
diff --git a/test/web/server-picker.test.js b/test/web/server-picker.test.js
index 2d5a0f5..5a994b9 100644
--- a/test/web/server-picker.test.js
+++ b/test/web/server-picker.test.js
@@ -36,11 +36,9 @@ test('server picker handles 1, 30 and 300 stable-ID servers with duplicate label
assert.equal(SERVER_RESULT_WINDOW, 60);
});
-test('server picker checks health once on load, keeps manual refresh and bounds the result window', () => {
+test('server picker checks health only on manual refresh and bounds the result window', () => {
assert.doesNotMatch(overview, /pingAll|servers\.ping/);
- assert.match(picker, /const initialCheckStarted = useRef\(false\)/);
- assert.match(picker, /if \(initialCheckStarted\.current \|\| !servers\.length\) return/);
- assert.match(picker, /initialCheckStarted\.current = true;\s*checkVisible\(\)/);
+ assert.doesNotMatch(picker, /checkVisible\(\);/);
assert.match(picker, /onClick={checkVisible}/);
assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/);
assert.match(picker, /700 - \(performance\.now\(\) - startedAt\)/);