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\)/);