diff --git a/docs/product/error-contract.md b/docs/product/error-contract.md new file mode 100644 index 0000000..d1def36 --- /dev/null +++ b/docs/product/error-contract.md @@ -0,0 +1,22 @@ +# Harbor error contract v1 + +Public API failures use one envelope: + +```json +{ + "success": false, + "error": { + "code": "PROVIDER_UNAVAILABLE", + "message": "Провайдер подписки временно недоступен.", + "retryable": true, + "correlationId": "6f1a63de-30f9-4dc5-b8ce-38d38c164fe3", + "details": "HTTP 503" + } +} +``` + +`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.js`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs. + +Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`. + +This is a coordinated API change: old frontends do not understand the object-valued `error` field, so frontend and control plane must be deployed together. Persisted files and volumes are unchanged. Rollback is code-only and requires no data migration. diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 7499fba..fddce30 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -1,4 +1,5 @@ import http from 'node:http'; +import { HarborError } from '../shared/errors.js'; function request(socketPath, pathname, method = 'GET') { return new Promise((resolve, reject) => { @@ -27,8 +28,15 @@ function request(socketPath, pathname, method = 'GET') { export function createDataplaneClient(socketPath, send = request) { let current = { running: false, startedAt: null }; const update = async (pathname, method) => { - current = await send(socketPath, pathname, method); - return current; + try { + current = await send(socketPath, pathname, method); + return current; + } catch (cause) { + if (pathname === '/apply' || pathname === '/restart') { + throw new HarborError('PROCESS_START_FAILED', { cause }); + } + throw cause; + } }; return { diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.js index f43c1bd..7c6fbb5 100644 --- a/src/server/gatewayPresence.js +++ b/src/server/gatewayPresence.js @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; +import { HarborError } from '../shared/errors.js'; const NONCE_RE = /^[a-f0-9]{32}$/; const PROOF_RE = /^[a-f0-9]{64}$/; @@ -51,9 +52,7 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) { export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) { if (!NONCE_RE.test(String(nonce || ''))) { - const error = new Error('Некорректный nonce'); - error.statusCode = 400; - throw error; + throw new HarborError('REQUEST_INVALID'); } const subscription = String(subscriptionUrl || '').trim(); diff --git a/src/server/index.js b/src/server/index.js index 7d45261..8e50703 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; @@ -27,6 +28,7 @@ import { normalizeStoredState, withStateV0Compatibility, } from '../shared/contracts/state.js'; +import { HarborError, normalizeHarborError } from '../shared/errors.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -91,10 +93,11 @@ async function withOperation(kind, operation) { updateStoredState((state) => state); return result; } catch (error) { + const harborError = normalizeHarborError(error); operationState = { ...operationState, status: 'failed', - error: error?.message || String(error), + error: harborError.message, }; updateStoredState((state) => state); throw error; @@ -103,7 +106,8 @@ async function withOperation(kind, operation) { function serializeControl(operation) { const result = controlOperation.then(operation, operation); - controlOperation = result.catch(() => {}); + // The caller observes result; this settled tail only keeps the next operation runnable. + controlOperation = result.then(() => undefined, () => undefined); return result; } @@ -112,6 +116,29 @@ function sendJson(res, statusCode, payload) { res.end(JSON.stringify(payload)); } +function redactLogDetails(value) { + return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]'); +} + +function sendError(res, error) { + const harborError = normalizeHarborError(error); + const correlationId = crypto.randomUUID(); + const technical = harborError.cause?.message || harborError.details || error; + console.error( + `[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`, + ); + return sendJson(res, harborError.status, { + success: false, + error: { + code: harborError.code, + message: harborError.message, + retryable: harborError.retryable, + correlationId, + ...(harborError.details ? { details: harborError.details } : {}), + }, + }); +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -122,9 +149,7 @@ function readBody(req) { size += chunk.length; if (size > MAX_BODY_BYTES) { tooLarge = true; - const error = new Error('Тело запроса слишком большое'); - error.statusCode = 413; - reject(error); + reject(new HarborError('REQUEST_INVALID')); return; } chunks.push(chunk); @@ -134,10 +159,8 @@ function readBody(req) { if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - } catch { - const error = new Error('Невалидный JSON в теле запроса'); - error.statusCode = 400; - reject(error); + } catch (cause) { + reject(new HarborError('REQUEST_INVALID', { cause })); } }); req.on('error', reject); @@ -312,7 +335,8 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) { async function applySelectedServer(selectedTag, { persist = true } = {}) { const cached = readJson(settings.subscriptionCachePath, null); - if (!cached?.config) throw new Error('Сначала загрузите подписку'); + if (!cached?.config) throw new HarborError('CONFIG_INVALID'); + const nextConfig = buildActiveConfig(cached.config, selectedTag); if (persist) { updateStoredState((state) => ({ @@ -325,7 +349,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) { const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; - writeSingboxConfig(buildActiveConfig(cached.config, selectedTag)); + writeSingboxConfig(nextConfig); try { await startSingbox(); } catch (error) { @@ -348,9 +372,7 @@ function refreshSavedSubscription() { subscriptionRefreshPromise = (async () => { const initialState = readJson(settings.statePath, {}); if (!initialState.subscriptionUrl) { - const error = new Error('Подписка не настроена'); - error.statusCode = 400; - throw error; + throw new HarborError('SUBSCRIPTION_INVALID'); } const subscriptionUrl = initialState.subscriptionUrl; @@ -358,7 +380,7 @@ function refreshSavedSubscription() { return serializeControl(async () => { const currentState = readJson(settings.statePath, {}); if (currentState.subscriptionUrl !== subscriptionUrl) { - throw new Error('Подписка была изменена во время обновления'); + throw new HarborError('STATE_CONFLICT'); } const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); @@ -498,11 +520,11 @@ async function handleApi(req, res) { if (req.method === 'POST' && req.url === '/api/gateway-auto') { if (settings.appMode !== 'client') { - return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' }); + throw new HarborError('REQUEST_INVALID'); } const { enabled } = await readBody(req); if (typeof enabled !== 'boolean') { - return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); + throw new HarborError('REQUEST_INVALID'); } await withOperation('gateway-auto', () => serializeControl(async () => { await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); @@ -529,7 +551,7 @@ async function handleApi(req, res) { if (req.method === 'POST' && req.url === '/api/apply') { const { selectedTag = '' } = await readBody(req); const tag = String(selectedTag).trim(); - if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); + if (!tag) throw new HarborError('REQUEST_INVALID'); await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag))); return sendState(res, { selectedTag: tag }); } @@ -545,9 +567,7 @@ async function handleApi(req, res) { if (req.method === 'POST' && req.url === '/api/singbox/restart') { await withOperation('start', () => serializeControl(async () => { if (!fs.existsSync(settings.configPath)) { - const error = new Error('Сначала выберите сервер'); - error.statusCode = 400; - throw error; + throw new HarborError('CONFIG_INVALID'); } await singboxRuntime.restart(); updateStoredState((state) => ({ @@ -559,7 +579,7 @@ async function handleApi(req, res) { return sendState(res, { singboxRunning: true }); } - return sendJson(res, 404, { success: false, error: 'Не найдено' }); + return sendError(res, new HarborError('ENDPOINT_NOT_FOUND')); } const mime = { @@ -592,11 +612,7 @@ const server = http.createServer(async (req, res) => { ? await handleApi(req, res) : serveStatic(req, res); } catch (error) { - console.error('[control] request failed', error); - return sendJson(res, error.statusCode || 500, { - success: false, - error: error.message || String(error), - }); + return sendError(res, error); } }); diff --git a/src/server/singbox.js b/src/server/singbox.js index 516f4ca..2cbe2be 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { settings } from './config.js'; +import { HarborError } from '../shared/errors.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const MIXED_INBOUND = 'mixed-in'; @@ -22,7 +23,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire const vpnOutbound = directClient ? null : structuredClone(findOutbound(subscriptionConfig, selectedTag)); - if (!directClient && !vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`); + if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND'); if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = 'xudp'; diff --git a/src/server/singboxRuntime.js b/src/server/singboxRuntime.js index 27b2c9a..be8205a 100644 --- a/src/server/singboxRuntime.js +++ b/src/server/singboxRuntime.js @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { setGatewayInterception } from './gatewayRouting.js'; +import { HarborError } from '../shared/errors.js'; export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) { let child = null; @@ -44,16 +45,27 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); if (check.status !== 0) { - throw new Error((check.stderr || check.stdout || 'sing-box check failed').trim()); + throw new HarborError('CONFIG_INVALID', { + cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), + }); } const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); if (!force && child && nextHash === configHash) return state(); await stop(); - const current = spawn('sing-box', ['run', '-c', configPath], { - stdio: ['ignore', 'inherit', 'inherit'], - }); + let current; + try { + current = spawn('sing-box', ['run', '-c', configPath], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + await new Promise((resolve, reject) => { + current.once('spawn', resolve); + current.once('error', reject); + }); + } catch (cause) { + throw new HarborError('PROCESS_START_FAILED', { cause }); + } child = current; configHash = nextHash; startedAt = new Date().toISOString(); @@ -64,7 +76,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain child = null; configHash = ''; startedAt = null; - throw error; + throw new HarborError('PROCESS_START_FAILED', { cause: error }); } current.once('exit', () => { if (child !== current) return; diff --git a/src/server/subscription.js b/src/server/subscription.js index 33be146..8453f66 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.js @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import { settings } from './config.js'; +import { HarborError } from '../shared/errors.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); @@ -40,10 +41,15 @@ export function parseUserInfo(headerValue) { export function parseVlessUrl(rawUrl) { if (!rawUrl.startsWith('vless://')) { - throw new Error('VLESS URL must start with vless://'); + throw new HarborError('SUBSCRIPTION_INVALID'); } - const parsed = new URL(rawUrl); + let parsed; + try { + parsed = new URL(rawUrl); + } catch (cause) { + throw new HarborError('SUBSCRIPTION_INVALID', { cause }); + } const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out'); const uuid = decodeURIComponent(parsed.username || ''); const server = parsed.hostname; @@ -55,11 +61,11 @@ export function parseVlessUrl(rawUrl) { const flow = parsed.searchParams.get('flow') || ''; if (!uuid || !server || !serverPort) { - throw new Error('VLESS URL misses uuid, host or port'); + throw new HarborError('SUBSCRIPTION_INVALID'); } if (!publicKey || !shortId) { - throw new Error('VLESS REALITY parameters pbk and sid are required'); + throw new HarborError('SUBSCRIPTION_INVALID'); } return { @@ -111,7 +117,7 @@ export function parseSubscriptionBody(body) { .filter((line) => line.startsWith('vless://')); if (!links.length) { - throw new Error('Subscription does not contain JSON config or VLESS links'); + throw new HarborError('SUBSCRIPTION_INVALID'); } parsedConfig = { @@ -130,7 +136,7 @@ export function parseSubscriptionBody(body) { })); if (!servers.length) { - throw new Error('No supported proxy outbounds found in subscription'); + throw new HarborError('SUBSCRIPTION_INVALID'); } return { config: parsedConfig, servers }; @@ -140,21 +146,26 @@ async function requestSubscription(url) { let parsedUrl; try { parsedUrl = new URL(url); - } catch { - throw new Error('Invalid subscription URL'); + } catch (cause) { + throw new HarborError('SUBSCRIPTION_INVALID', { cause }); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - throw new Error('Subscription URL must use http or https'); + throw new HarborError('SUBSCRIPTION_INVALID'); } - const response = await fetch(parsedUrl, { - headers: subscriptionHeaders(), - redirect: 'follow', - }); + let response; + try { + response = await fetch(parsedUrl, { + headers: subscriptionHeaders(), + redirect: 'follow', + }); + } catch (cause) { + throw new HarborError('PROVIDER_UNAVAILABLE', { cause }); + } if (!response.ok) { - throw new Error(`Subscription request failed: HTTP ${response.status}`); + throw new HarborError('PROVIDER_UNAVAILABLE', { details: `HTTP ${response.status}` }); } return response; diff --git a/src/shared/errors.js b/src/shared/errors.js new file mode 100644 index 0000000..8f0aa81 --- /dev/null +++ b/src/shared/errors.js @@ -0,0 +1,33 @@ +export const ERROR_DEFINITIONS = Object.freeze({ + CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true }, + REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false }, + ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false }, + SUBSCRIPTION_INVALID: { 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 }, + CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false }, + PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true }, + OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true }, + UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false }, +}); + +export function errorDefinition(code) { + return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN; +} + +export class HarborError extends Error { + constructor(code, { cause, details } = {}) { + const definition = errorDefinition(code); + super(definition.message, { cause }); + this.name = 'HarborError'; + this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN'; + this.status = definition.status; + this.retryable = definition.retryable; + this.details = details; + } +} + +export function normalizeHarborError(error) { + return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error }); +} diff --git a/src/web/App.jsx b/src/web/App.jsx index b5bbe2a..dbf61af 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useReducer, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import './styles.css'; -import { api } from './api.js'; +import { api, HarborApiError } from './api.js'; import { ClientOverviewPage } from './components/ClientOverviewPage.jsx'; import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx'; import { @@ -18,7 +18,7 @@ function App() { ); const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(null); const pollGeneration = useRef(0); function setPendingTag(serverId) { @@ -60,14 +60,21 @@ function App() { : '/harbor-connect.svg?v=2'; }, [state?.mode]); - async function run(action) { + async function run(action, context) { setBusy(true); - setError(''); + setError(null); try { return await applyMutation(action); } catch (err) { - setError(err.message); - throw err; + const safeError = err instanceof HarborApiError ? err : new HarborApiError(); + setError({ + context, + message: safeError.message, + code: safeError.code, + correlationId: safeError.correlationId, + retry: safeError.retryable ? () => run(action, context) : null, + }); + return false; } finally { setBusy(false); } @@ -91,11 +98,11 @@ function App() { const data = await api.subscription.fetch(subscriptionUrl); dispatch({ type: 'clear-pending-server' }); return data; - }); + }, 'subscription'); } async function refreshSubscription() { - return applyMutation(api.subscription.refresh); + return run(api.subscription.refresh, 'subscription'); } async function forgetSubscription() { @@ -104,7 +111,7 @@ function App() { setSubscriptionUrl(''); dispatch({ type: 'clear-pending-server' }); return data; - }); + }, 'subscription'); } if (!state) return loadState({ retry: true })} />; @@ -126,10 +133,10 @@ function App() { onFetchSubscription={fetchSubscription} onRefreshSubscription={refreshSubscription} onForgetSubscription={forgetSubscription} - onApply={(tag) => run(() => api.apply(tag))} - onRestart={() => run(api.singbox.restart)} - onStop={() => run(api.singbox.stop)} - onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))} + onApply={(tag) => run(() => api.apply(tag), 'connection')} + onRestart={() => run(api.singbox.restart, 'connection')} + onStop={() => run(api.singbox.stop, 'connection')} + onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled), 'connection')} /> diff --git a/src/web/api.js b/src/web/api.js index 8954c94..70aa7e7 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -1,16 +1,51 @@ -async function request(url, options = {}) { - const response = await fetch(url, { - ...options, - headers: { - 'content-type': 'application/json', - ...(options.headers || {}), - }, - }); - const data = await response.json().catch(() => ({})); +import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js'; + +export class HarborApiError extends Error { + constructor(payload = {}, status = 0) { + const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN'; + const definition = errorDefinition(code); + super(definition.message); + this.name = 'HarborApiError'; + this.code = code; + this.status = status >= 400 ? status : definition.status; + this.retryable = definition.retryable; + this.details = payload.details; + this.correlationId = payload.correlationId + || globalThis.crypto?.randomUUID?.() + || new Date().toISOString(); + } +} + +export function validationStatusForError(error) { + return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable'; +} + +export async function request(url, options = {}, fetchImpl = fetch) { + let response; + try { + response = await fetchImpl(url, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, + }); + } catch (error) { + if (error?.name === 'AbortError') throw error; + throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' }); + } + + let data = {}; + try { + data = await response.json(); + } catch { + if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status); + } if (!response.ok || data?.success === false) { - const error = new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`); - error.status = response.status; - throw error; + const payload = data?.error && typeof data.error === 'object' + ? data.error + : { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' }; + throw new HarborApiError(payload, response.status); } return data; } diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 198f65d..928253b 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; -import { api } from '../api.js'; +import { api, validationStatusForError } from '../api.js'; import { connectionAction, connectionDurationParts, @@ -20,6 +20,19 @@ function CloudTooltip({ children }) { return {children}; } +function InlineError({ error, context }) { + if (!error || error.context !== context) return null; + return ( +
+ {error.message} + {error.correlationId && ( + Код: {error.correlationId.slice(0, 8)} + )} + {error.retry && } +
+ ); +} + function InstructionStep({ step }) { if (typeof step === 'string') return step; return ( @@ -196,6 +209,7 @@ export function ClientOverviewPage({ }); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' }); + const [validationAttempt, setValidationAttempt] = useState(0); const [showIntro, setShowIntro] = useState(!hasSubscription); const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription); const [pings, setPings] = useState({}); @@ -240,6 +254,9 @@ export function ClientOverviewPage({ const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl ? subscriptionValidation.status : normalizedSubscriptionUrl ? 'checking' : 'idle'; + const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl + ? subscriptionValidation.error + : null; const subscriptionWaiting = hasSubscription && !subscriptionContentReady; useEffect(() => { @@ -316,10 +333,32 @@ export function ClientOverviewPage({ setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' }); const timer = setTimeout(() => { api.subscription.validate(normalizedSubscriptionUrl, controller.signal) - .then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' })) - .catch(() => { + .then(() => setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: 'valid', + error: null, + })) + .catch((validationError) => { if (!controller.signal.aborted) { - setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' }); + setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: validationStatusForError(validationError), + error: { + context: 'subscription', + message: validationError.message, + correlationId: validationError.correlationId, + retry: validationError.retryable + ? () => { + setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: 'checking', + error: null, + }); + setValidationAttempt((attempt) => attempt + 1); + } + : null, + }, + }); } }); }, 350); @@ -328,7 +367,7 @@ export function ClientOverviewPage({ clearTimeout(timer); controller.abort(); }; - }, [normalizedSubscriptionUrl]); + }, [normalizedSubscriptionUrl, validationAttempt]); useEffect(() => { if (!hasSubscription) { @@ -356,7 +395,7 @@ export function ClientOverviewPage({ useEffect(() => { if (!state?.hasSubscription) return undefined; - onRefreshSubscription().catch(() => {}); + onRefreshSubscription(); return undefined; }, [state?.hasSubscription]); @@ -413,7 +452,7 @@ export function ClientOverviewPage({ async function submitSubscription(event) { event.preventDefault(); if (subscriptionValidationStatus !== 'valid') return; - await onFetchSubscription(); + if (!await onFetchSubscription()) return; setSubscriptionUrl(''); setEditingSubscription(false); } @@ -434,7 +473,7 @@ export function ClientOverviewPage({ const startedAt = performance.now(); setRefreshingInfo(true); try { - await onRefreshSubscription(); + if (!await onRefreshSubscription()) return; setUsageUpdated(false); requestAnimationFrame(() => setUsageUpdated(true)); setTimeout(() => setUsageUpdated(false), 900); @@ -451,7 +490,7 @@ export function ClientOverviewPage({ } async function forgetSubscription() { - await onForgetSubscription(); + if (!await onForgetSubscription()) return; setConfirmingDelete(false); } @@ -641,11 +680,10 @@ export function ClientOverviewPage({ )} + )} - {error &&

{error}

} -
{subscriptionValidationStatus === 'valid' ? '✓' - : subscriptionValidationStatus === 'invalid' ? '×' : '…'} + : subscriptionValidationStatus === 'invalid' + ? '×' + : subscriptionValidationStatus === 'unavailable' ? '!' : '…'} )} +
{hasSubscription && subscriptionContentReady && hasUsage && ( diff --git a/src/web/state/harborReducer.js b/src/web/state/harborReducer.js index b8d2bc3..62988b6 100644 --- a/src/web/state/harborReducer.js +++ b/src/web/state/harborReducer.js @@ -22,7 +22,7 @@ export function compatibleSnapshot(snapshot) { export function classifySyncError(error) { const status = Number(error?.status) || 0; if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api'; - if (error?.name === 'TypeError' || status >= 500) return 'control-unreachable'; + if (error?.code === 'CONTROL_UNREACHABLE' || error?.name === 'TypeError' || status >= 500) return 'control-unreachable'; return 'fatal'; } diff --git a/src/web/styles.css b/src/web/styles.css index 13ac727..a63c072 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -876,6 +876,7 @@ p { } .client-power-section { + position: relative; display: grid; justify-items: center; gap: 18px; @@ -1896,17 +1897,53 @@ p { margin-top: 7px; } -.client-error { +.client-inline-error { position: absolute; - bottom: 0; left: 50%; - width: min(420px, 90vw); + z-index: 3; + width: min(360px, 90vw); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; color: oklch(0.62 0.2 28); font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; text-align: center; transform: translateX(-50%); } +.client-inline-error.is-connection { + top: calc(100% + 12px); +} + +.client-inline-error.is-subscription { + top: calc(100% + 2px); +} + +.client-inline-error small { + color: var(--client-muted); + font-size: 9px; + white-space: nowrap; +} + +.client-inline-error button { + padding: 4px 7px; + border: 0; + border-radius: 6px; + background: color-mix(in oklch, var(--client-control) 72%, transparent); + color: var(--client-text); + font-size: 9px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +.client-inline-error button:focus-visible { + outline: 2px solid oklch(0.68 0.15 28); + outline-offset: 2px; +} + .client-copy-button { position: relative; width: 86px; @@ -2009,6 +2046,19 @@ p { padding: 20px 6px; } + .client-power-section { + padding-bottom: 40px; + } + + .client-inline-error.is-connection { + top: auto; + bottom: 0; + } + + .client-form-content { + gap: 48px; + } + .harbor-brand { transform: translate(-50%, calc(-50% - 28vh)) scale(2.15); } diff --git a/test/server/errors.test.js b/test/server/errors.test.js new file mode 100644 index 0000000..a7c91fc --- /dev/null +++ b/test/server/errors.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ERROR_DEFINITIONS, + HarborError, + normalizeHarborError, +} from '../../src/shared/errors.js'; + +test('every Harbor error code has stable Russian copy and retry policy', () => { + for (const [code, definition] of Object.entries(ERROR_DEFINITIONS)) { + const error = new HarborError(code); + assert.equal(error.code, code); + assert.equal(error.message, definition.message); + assert.equal(error.retryable, definition.retryable); + assert.match(error.message, /[А-Яа-яЁё]/); + assert.equal(typeof error.status, 'number'); + } +}); + +test('unknown failures use the safe non-retryable fallback', () => { + const error = normalizeHarborError(new Error('secret internal failure')); + + assert.equal(error.code, 'UNKNOWN'); + assert.equal(error.message, ERROR_DEFINITIONS.UNKNOWN.message); + assert.equal(error.retryable, false); + assert.equal(error.message.includes('secret'), false); +}); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index c26e4bf..0233379 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -29,13 +29,18 @@ async function freePort() { return port; } -async function request(port, pathname, method = 'GET', body) { +async function rawRequest(port, pathname, method = 'GET', body) { const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await response.json(); + return { response, payload }; +} + +async function request(port, pathname, method = 'GET', body) { + const { response, payload } = await rawRequest(port, pathname, method, body); assert.equal(response.ok, true, JSON.stringify(payload)); return payload; } @@ -54,7 +59,7 @@ async function waitForState(port, child, stderr) { test('state v1 normalizes legacy storage and validates the canonical snapshot', () => { const stored = normalizeStoredState({ - subscriptionUrl: 'https://provider.example/subscription/full-secret-token', + subscriptionUrl: 'https://provider.example/subscription/test', selectedTag: ' legacy ', servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }], }); @@ -95,14 +100,20 @@ test('GET and domain mutations return one state shape with monotonic revisions', }], }; fs.mkdirSync(binDir); - fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node + const singboxPath = path.join(binDir, 'sing-box'); + const workingSingbox = `#!/usr/bin/env node if (process.argv[2] === 'check') process.exit(0); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 60_000); -`); - fs.chmodSync(path.join(binDir, 'sing-box'), 0o755); +`; + fs.writeFileSync(singboxPath, workingSingbox); + fs.chmodSync(singboxPath, 0o755); const subscriptionServer = http.createServer((req, res) => { + if (req.url === '/unavailable') { + res.writeHead(503); + return res.end('unavailable'); + } res.writeHead(200, { 'content-type': 'application/json', 'subscription-userinfo': 'upload=10; download=20; total=100', @@ -110,7 +121,7 @@ setInterval(() => {}, 60_000); res.end(JSON.stringify(config)); }); const subscriptionPort = await listen(subscriptionServer); - const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/full-secret-token`; + const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/test`; fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({ subscriptionUrl, selectedTag: 'test-vpn', @@ -150,6 +161,42 @@ setInterval(() => {}, 60_000); const stateKeys = Object.keys(initial).sort(); let revision = initial.revision; + const invalidSubscription = await rawRequest( + port, + '/api/subscription/validate', + 'POST', + { url: 'not-a-url' }, + ); + assert.equal(invalidSubscription.response.status, 400); + assert.deepEqual( + { + code: invalidSubscription.payload.error.code, + retryable: invalidSubscription.payload.error.retryable, + }, + { code: 'SUBSCRIPTION_INVALID', retryable: false }, + ); + assert.equal(typeof invalidSubscription.payload.error.correlationId, 'string'); + + const providerUnavailable = await rawRequest( + port, + '/api/subscription/validate', + 'POST', + { url: `http://127.0.0.1:${subscriptionPort}/unavailable` }, + ); + assert.equal(providerUnavailable.response.status, 502); + assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE'); + assert.equal(providerUnavailable.payload.error.retryable, true); + + const missingServer = await rawRequest( + port, + '/api/apply', + 'POST', + { selectedTag: 'missing-server' }, + ); + assert.equal(missingServer.response.status, 404); + assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND'); + assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn'); + async function stateResponse(pathname, method = 'POST', body) { const result = await request(port, pathname, method, body); assert.deepEqual(Object.keys(result.state).sort(), stateKeys); @@ -174,10 +221,30 @@ setInterval(() => {}, 60_000); assert.equal(applied.state.connection.process, 'running'); assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped'); assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); + + fs.writeFileSync(singboxPath, `#!/usr/bin/env node +if (process.argv[2] === 'check') { + require('node:fs').unlinkSync(process.argv[1]); + process.exit(0); +} +`); + fs.chmodSync(singboxPath, 0o755); + const processFailure = await rawRequest(port, '/api/singbox/restart', 'POST'); + assert.equal(processFailure.response.status, 503); + assert.equal(processFailure.payload.error.code, 'PROCESS_START_FAILED'); + assert.equal(processFailure.payload.error.retryable, true); + fs.writeFileSync(singboxPath, workingSingbox); + fs.chmodSync(singboxPath, 0o755); + await mutation('/api/subscription/refresh'); assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false); const forgotten = await mutation('/api/subscription', 'DELETE'); assert.equal(forgotten.state.subscription.status, 'missing'); assert.equal(forgotten.state.servers.length, 0); assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []); + + const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST'); + assert.equal(missingConfig.response.status, 422); + assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID'); + assert.equal(missingConfig.payload.error.retryable, false); }); diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js new file mode 100644 index 0000000..88cf134 --- /dev/null +++ b/test/web/api-errors.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + HarborApiError, + request, + validationStatusForError, +} from '../../src/web/api.js'; + +const response = (status, error) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => ({ success: false, error }), +}); + +test('frontend exposes retry only for retryable structured errors', async () => { + await assert.rejects( + request('/api/test', {}, async () => response(502, { + code: 'PROVIDER_UNAVAILABLE', + message: 'untrusted server copy', + retryable: false, + correlationId: 'provider-reference', + })), + (error) => { + assert.ok(error instanceof HarborApiError); + assert.equal(error.message, 'Провайдер подписки временно недоступен.'); + assert.equal(error.retryable, true); + assert.equal(error.correlationId, 'provider-reference'); + return true; + }, + ); + + await assert.rejects( + request('/api/test', {}, async () => response(400, { + code: 'SUBSCRIPTION_INVALID', + retryable: true, + })), + (error) => error.retryable === false, + ); +}); + +test('network failures become retryable control errors', async () => { + await assert.rejects( + request('/api/state', {}, async () => { throw new TypeError('fetch failed'); }), + (error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable === true, + ); +}); + +test('local unknown errors get a safe message and diagnostic reference', () => { + const error = new HarborApiError({ code: 'NOT_A_REAL_CODE' }); + + assert.equal(error.code, 'UNKNOWN'); + assert.equal(error.message, 'Не удалось выполнить действие.'); + assert.equal(typeof error.correlationId, 'string'); + assert.ok(error.correlationId.length >= 8); +}); + +test('subscription validation distinguishes bad input from provider outage', () => { + assert.equal(validationStatusForError({ code: 'SUBSCRIPTION_INVALID' }), 'invalid'); + assert.equal(validationStatusForError({ code: 'PROVIDER_UNAVAILABLE' }), 'unavailable'); +});