Unify Harbor error handling across server and client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-11 20:38:41 +03:00
parent 457dd912d1
commit 9da4fef1f0
16 changed files with 493 additions and 100 deletions

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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);
}
});

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;