806 lines
27 KiB
JavaScript
806 lines
27 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { isDeepStrictEqual } from 'node:util';
|
|
import { createDataplaneClient } from './dataplaneClient.js';
|
|
import { settings } from './config.js';
|
|
import {
|
|
applyGatewayPreference,
|
|
buildGatewayPresence,
|
|
createGatewayAutoState,
|
|
nextGatewayAutoState,
|
|
probeGatewayPresence,
|
|
readHostNetworkState,
|
|
sameGatewayRoute,
|
|
} from './gatewayPresence.js';
|
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
|
import { tcpPing } from './ping.js';
|
|
import { checkServerHealth } from './serverHealth.js';
|
|
import { buildSharedProxyInfo } from './sharedProxy.js';
|
|
import {
|
|
buildGatewayConfig,
|
|
removeSingboxConfig,
|
|
restoreSingboxConfig,
|
|
writeSingboxConfig,
|
|
} from './singbox.js';
|
|
import {
|
|
fetchSubscription,
|
|
getHwid,
|
|
normalizeSubscriptionConfig,
|
|
selectRefreshedServer,
|
|
} from './subscription.js';
|
|
import {
|
|
createStateSnapshot,
|
|
normalizeStoredState,
|
|
withStateV0Compatibility,
|
|
} from '../shared/contracts/state.js';
|
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
|
import { normalizeRouteRules } from '../shared/routingRules.js';
|
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
|
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 });
|
|
|
|
const stateStore = createStateStore(settings.statePath);
|
|
const subscriptionCacheStore = createJsonStore({
|
|
filePath: settings.subscriptionCachePath,
|
|
defaultValue: null,
|
|
});
|
|
let cacheRecoveryLogged = false;
|
|
|
|
function readSubscriptionCache() {
|
|
const cached = subscriptionCacheStore.read();
|
|
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
|
cacheRecoveryLogged = true;
|
|
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
|
}
|
|
return cached?.config
|
|
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
|
|
: cached;
|
|
}
|
|
|
|
const initialStoredState = stateStore.read();
|
|
if (stateStore.migration) {
|
|
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
|
}
|
|
if (stateStore.recovery) {
|
|
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
|
}
|
|
|
|
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
|
const versionInfo = buildVersionInfo(settings.appMode);
|
|
const singboxRuntime = remoteDataplane
|
|
? createDataplaneClient(settings.dataplaneSocket)
|
|
: createSingboxRuntime({
|
|
configPath: settings.configPath,
|
|
gateway: settings.appMode === 'gateway',
|
|
tproxyChain: settings.tproxyChain,
|
|
});
|
|
let subscriptionRefreshPromise = null;
|
|
let subscriptionRefreshTimer = null;
|
|
let gatewayDiscoveryPromise = null;
|
|
let gatewayDiscoveryTimer = null;
|
|
let gatewayAutoState = createGatewayAutoState();
|
|
let controlOperation = Promise.resolve();
|
|
let operationState = stateStore.recovery ? {
|
|
kind: 'storage-recovery',
|
|
status: 'failed',
|
|
startedAt: stateStore.recovery.recoveredAt,
|
|
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
|
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
|
let revision = normalizeStoredState(initialStoredState).revision;
|
|
|
|
function updateStoredState(update) {
|
|
return stateStore.update((stored) => {
|
|
const current = normalizeStoredState(stored);
|
|
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
|
|
revision = Math.max(revision, current.revision) + 1;
|
|
next.revision = revision;
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function withOperation(kind, operation) {
|
|
operationState = {
|
|
kind,
|
|
status: 'running',
|
|
startedAt: new Date().toISOString(),
|
|
error: null,
|
|
};
|
|
updateStoredState((state) => state);
|
|
try {
|
|
const result = await operation();
|
|
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
|
updateStoredState((state) => state);
|
|
return result;
|
|
} catch (error) {
|
|
const harborError = normalizeHarborError(error);
|
|
operationState = {
|
|
...operationState,
|
|
status: 'failed',
|
|
error: harborError.message,
|
|
};
|
|
updateStoredState((state) => state);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function serializeControl(operation) {
|
|
const result = controlOperation.then(operation, operation);
|
|
// The caller observes result; this settled tail only keeps the next operation runnable.
|
|
controlOperation = result.then(() => undefined, () => undefined);
|
|
return result;
|
|
}
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
|
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 = [];
|
|
let size = 0;
|
|
let tooLarge = false;
|
|
req.on('data', (chunk) => {
|
|
if (tooLarge) return;
|
|
size += chunk.length;
|
|
if (size > MAX_BODY_BYTES) {
|
|
tooLarge = true;
|
|
reject(new HarborError('REQUEST_INVALID'));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => {
|
|
if (tooLarge) return;
|
|
if (!chunks.length) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
} catch (cause) {
|
|
reject(new HarborError('REQUEST_INVALID', { cause }));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function subscriptionHost(url) {
|
|
try {
|
|
return `${new URL(url).host}/…`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function buildActiveConfig(
|
|
subscriptionConfig,
|
|
selectedServerId,
|
|
routeRules = stateStore.read().routeRules,
|
|
) {
|
|
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
|
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
|
routeRules,
|
|
});
|
|
}
|
|
|
|
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());
|
|
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
|
const configExists = fs.existsSync(settings.configPath);
|
|
const snapshot = createStateSnapshot({
|
|
storedState: state,
|
|
runtime,
|
|
gatewayAuto: gatewayAutoState,
|
|
appMode: settings.appMode,
|
|
configExists,
|
|
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
|
operation: operationState,
|
|
});
|
|
return withStateV0Compatibility(snapshot, {
|
|
storedState: { ...state, gatewayAutoEnabled },
|
|
gatewayAuto: gatewayAutoState,
|
|
port: settings.port,
|
|
proxyPort: settings.proxyPort,
|
|
configExists,
|
|
});
|
|
}
|
|
|
|
function writeCurrentConfig() {
|
|
const state = stateStore.read();
|
|
const cached = readSubscriptionCache();
|
|
if (!state.selectedServerId || !cached?.config) return false;
|
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
|
return true;
|
|
}
|
|
|
|
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
|
const previousState = gatewayAutoState;
|
|
const stateChanged = !isDeepStrictEqual(previousState, nextState);
|
|
const modeChanged = previousState.mode !== nextState.mode;
|
|
gatewayAutoState = nextState;
|
|
if (!modeChanged) {
|
|
if (stateChanged) updateStoredState((state) => state);
|
|
return;
|
|
}
|
|
|
|
const previousConfig = fs.existsSync(settings.configPath)
|
|
? fs.readFileSync(settings.configPath, 'utf8')
|
|
: null;
|
|
const wasRunning = singboxRuntime.running;
|
|
try {
|
|
const configured = writeCurrentConfig();
|
|
if (reconfigure && configured && wasRunning) await startSingbox();
|
|
} catch (error) {
|
|
gatewayAutoState = previousState;
|
|
if (previousConfig === null) removeSingboxConfig();
|
|
else restoreSingboxConfig(previousConfig);
|
|
throw error;
|
|
}
|
|
|
|
if (stateChanged) updateStoredState((state) => state);
|
|
|
|
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
|
|
console.log(`[control] client route: ${nextState.mode}${route}`);
|
|
}
|
|
|
|
function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
|
if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState);
|
|
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
|
|
|
|
gatewayDiscoveryPromise = serializeControl(async () => {
|
|
const state = stateStore.read();
|
|
const network = state.subscriptionUrl
|
|
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
: null;
|
|
|
|
if (!network) {
|
|
const nextState = nextGatewayAutoState(gatewayAutoState, { network: null });
|
|
if (state.subscriptionUrl) {
|
|
nextState.lastError = 'macOS default gateway недоступен или устарел';
|
|
}
|
|
await applyGatewayAutoState(
|
|
nextState,
|
|
{ reconfigure },
|
|
);
|
|
return gatewayAutoState;
|
|
}
|
|
|
|
if (
|
|
gatewayAutoState.mode === 'gateway-direct' &&
|
|
!sameGatewayRoute(gatewayAutoState.gateway, network)
|
|
) {
|
|
await applyGatewayAutoState(
|
|
nextGatewayAutoState(gatewayAutoState, { network }),
|
|
{ reconfigure },
|
|
);
|
|
}
|
|
|
|
try {
|
|
const verifiedGateway = await probeGatewayPresence({
|
|
gateway: network.gateway,
|
|
port: settings.gatewayPresencePort,
|
|
subscriptionUrl: state.subscriptionUrl,
|
|
});
|
|
const latestState = stateStore.read();
|
|
const latestNetwork = latestState.subscriptionUrl
|
|
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
: null;
|
|
if (
|
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
|
!sameGatewayRoute(network, latestNetwork)
|
|
) {
|
|
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
|
return gatewayAutoState;
|
|
}
|
|
await applyGatewayAutoState(
|
|
applyGatewayPreference(
|
|
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
|
latestState.gatewayAutoEnabled !== false,
|
|
),
|
|
{ reconfigure },
|
|
);
|
|
} catch (error) {
|
|
const reason = error?.message || 'Gateway presence check failed';
|
|
const latestState = stateStore.read();
|
|
const latestNetwork = latestState.subscriptionUrl
|
|
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
: null;
|
|
if (
|
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
|
!sameGatewayRoute(network, latestNetwork)
|
|
) {
|
|
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
|
return gatewayAutoState;
|
|
}
|
|
if (gatewayAutoState.lastError !== reason) {
|
|
console.warn(`[control] Gateway не используется: ${reason}`);
|
|
}
|
|
await applyGatewayAutoState(
|
|
applyGatewayPreference(
|
|
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
|
latestState.gatewayAutoEnabled !== false,
|
|
),
|
|
{ reconfigure },
|
|
);
|
|
}
|
|
return gatewayAutoState;
|
|
}).finally(() => {
|
|
gatewayDiscoveryPromise = null;
|
|
});
|
|
|
|
return gatewayDiscoveryPromise;
|
|
}
|
|
|
|
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
|
|
const cached = readSubscriptionCache();
|
|
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
|
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
|
|
|
|
if (persist) {
|
|
updateStoredState((state) => ({
|
|
...state,
|
|
selectedServerId,
|
|
connectionDesired: 'running',
|
|
}));
|
|
}
|
|
|
|
const previousConfig = fs.existsSync(settings.configPath)
|
|
? fs.readFileSync(settings.configPath, 'utf8')
|
|
: null;
|
|
writeSingboxConfig(nextConfig);
|
|
try {
|
|
await startSingbox();
|
|
} catch (error) {
|
|
if (previousConfig === null) removeSingboxConfig();
|
|
else restoreSingboxConfig(previousConfig);
|
|
throw error;
|
|
}
|
|
updateStoredState((state) => ({
|
|
...state,
|
|
...(persist ? {
|
|
appliedServerId: selectedServerId,
|
|
appliedAt: new Date().toISOString(),
|
|
} : {}),
|
|
appliedRouteRules: state.routeRules,
|
|
}));
|
|
}
|
|
|
|
async function applyRouteRules(routeRules) {
|
|
const state = normalizeStoredState(stateStore.read());
|
|
const cached = readSubscriptionCache();
|
|
if (!state.selectedServerId || !cached?.config) {
|
|
updateStoredState((current) => ({
|
|
...current,
|
|
routeRules,
|
|
routeRulesRevision: current.routeRulesRevision + 1,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const previousConfig = fs.existsSync(settings.configPath)
|
|
? fs.readFileSync(settings.configPath, 'utf8')
|
|
: null;
|
|
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
|
try {
|
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
|
|
if (wasRunning) await startSingbox();
|
|
updateStoredState((current) => ({
|
|
...current,
|
|
routeRules,
|
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
|
routeRulesRevision: current.routeRulesRevision + 1,
|
|
}));
|
|
} catch (error) {
|
|
if (previousConfig === null) removeSingboxConfig();
|
|
else restoreSingboxConfig(previousConfig);
|
|
if (wasRunning) {
|
|
try {
|
|
await startSingbox();
|
|
} catch (rollbackError) {
|
|
throw new HarborError('PROCESS_START_FAILED', {
|
|
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
|
|
});
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
|
return serializeControl(async () => {
|
|
const previousState = normalizeStoredState(stateStore.read());
|
|
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
|
throw new HarborError('STATE_CONFLICT');
|
|
}
|
|
|
|
const selectedServerId = resetSelection
|
|
? ''
|
|
: selectRefreshedServer(
|
|
previousState.selectedServerId,
|
|
previousState.servers,
|
|
parsed.servers,
|
|
);
|
|
const candidateConfig = selectedServerId
|
|
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
|
|
: null;
|
|
const previousCache = readSubscriptionCache();
|
|
const previousConfig = fs.existsSync(settings.configPath)
|
|
? fs.readFileSync(settings.configPath, 'utf8')
|
|
: null;
|
|
const previousGatewayAutoState = gatewayAutoState;
|
|
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
|
|
|
try {
|
|
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
|
|
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
|
else removeSingboxConfig();
|
|
subscriptionCacheStore.write({
|
|
url: subscriptionUrl,
|
|
config: parsed.sourceConfig || parsed.config,
|
|
servers: parsed.servers,
|
|
userInfo: parsed.userInfo,
|
|
fetchedAt: parsed.fetchedAt,
|
|
});
|
|
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
|
|
|
updateStoredState((state) => ({
|
|
...(resetSelection ? {
|
|
routeRules: state.routeRules,
|
|
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
|
connectionDesired: 'stopped',
|
|
} : state),
|
|
subscriptionUrl,
|
|
servers: parsed.servers,
|
|
userInfo: parsed.userInfo,
|
|
fetchedAt: parsed.fetchedAt,
|
|
selectedServerId,
|
|
appliedServerId: selectedServerId,
|
|
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
|
}));
|
|
if (resetSelection) gatewayAutoState = createGatewayAutoState();
|
|
} catch (error) {
|
|
gatewayAutoState = previousGatewayAutoState;
|
|
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
|
|
else subscriptionCacheStore.remove();
|
|
if (previousConfig === null) removeSingboxConfig();
|
|
else restoreSingboxConfig(previousConfig);
|
|
if (wasRunning) {
|
|
try {
|
|
await startSingbox();
|
|
} catch (rollbackError) {
|
|
throw new HarborError('PROCESS_START_FAILED', {
|
|
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
|
|
});
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
servers: parsed.servers,
|
|
userInfo: parsed.userInfo,
|
|
fetchedAt: parsed.fetchedAt,
|
|
selectedServerId,
|
|
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
|
};
|
|
});
|
|
}
|
|
|
|
async function importSubscription(subscriptionUrl) {
|
|
const parsed = await fetchSubscription(subscriptionUrl);
|
|
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
|
|
}
|
|
|
|
function refreshSavedSubscription() {
|
|
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
|
|
|
subscriptionRefreshPromise = (async () => {
|
|
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;
|
|
});
|
|
|
|
return subscriptionRefreshPromise;
|
|
}
|
|
|
|
async function sendState(res, extra = {}) {
|
|
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
|
|
}
|
|
|
|
async function handleApi(req, res) {
|
|
if (req.method === 'GET' && req.url === '/api/state') {
|
|
return sendJson(res, 200, await publicState());
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/version') {
|
|
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
|
|
const runtime = await singboxRuntime.refresh();
|
|
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
|
return sendJson(res, 200, buildSharedProxyInfo({
|
|
appMode: settings.appMode,
|
|
proxyPort: settings.proxyPort,
|
|
running: (await singboxRuntime.refresh()).running,
|
|
hostHeader: req.headers.host,
|
|
sharedProxyHost: settings.sharedProxyHost,
|
|
}));
|
|
}
|
|
|
|
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
|
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
|
const state = stateStore.read();
|
|
return sendJson(res, 200, buildGatewayPresence({
|
|
appMode: settings.appMode,
|
|
subscriptionUrl: state.subscriptionUrl,
|
|
gatewayId: getHwid(),
|
|
nonce: requestUrl.searchParams.get('nonce'),
|
|
}));
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
|
const state = stateStore.read();
|
|
const { serverIds = [] } = await readBody(req);
|
|
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
|
const servers = requestedIds.size
|
|
? (state.servers || []).filter((server) => requestedIds.has(server.id))
|
|
: state.servers || [];
|
|
const results = await checkServerHealth(servers, tcpPing);
|
|
return sendState(res, { results });
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
|
const { url = '' } = await readBody(req);
|
|
const normalizedUrl = String(url).trim();
|
|
const parsed = await withOperation('subscription-import', async () => {
|
|
return importSubscription(normalizedUrl);
|
|
});
|
|
return sendState(res, parsed);
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
|
|
const { url = '' } = await readBody(req);
|
|
const parsed = await fetchSubscription(String(url).trim());
|
|
return sendState(res, { servers: parsed.servers.length });
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
|
const { success, ...result } = await withOperation(
|
|
'subscription-refresh',
|
|
() => refreshSavedSubscription(),
|
|
);
|
|
return sendState(res, result);
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
|
if (settings.appMode !== 'client') {
|
|
throw new HarborError('REQUEST_INVALID');
|
|
}
|
|
const { enabled } = await readBody(req);
|
|
if (typeof enabled !== 'boolean') {
|
|
throw new HarborError('REQUEST_INVALID');
|
|
}
|
|
await withOperation('gateway-auto', () => serializeControl(async () => {
|
|
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
|
updateStoredState((state) => ({
|
|
...state,
|
|
gatewayAutoEnabled: enabled,
|
|
}));
|
|
}));
|
|
const state = await publicState();
|
|
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
|
}
|
|
|
|
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
|
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
|
|
let routeRules;
|
|
try {
|
|
routeRules = normalizeRouteRules(rules, { strict: true });
|
|
} catch (cause) {
|
|
throw new HarborError('REQUEST_INVALID', { cause });
|
|
}
|
|
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
|
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
|
|
throw new HarborError('REQUEST_INVALID');
|
|
}
|
|
await serializeControl(async () => {
|
|
const current = normalizeStoredState(stateStore.read());
|
|
const currentRevision = expectedRulesRevision == null
|
|
? current.revision
|
|
: current.routeRulesRevision;
|
|
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
|
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
|
});
|
|
return sendState(res);
|
|
}
|
|
|
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
|
await withOperation('subscription-forget', () => resetSavedSubscription());
|
|
return sendState(res);
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/apply') {
|
|
const { serverId = '', selectedTag = '' } = await readBody(req);
|
|
const state = normalizeStoredState(stateStore.read());
|
|
const id = String(serverId).trim() || (() => {
|
|
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
|
|
return matches.length === 1 ? matches[0].id : '';
|
|
})();
|
|
if (!id || !state.servers.some((server) => server.id === id)) {
|
|
throw new HarborError('SERVER_NOT_FOUND');
|
|
}
|
|
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
|
|
return sendState(res, {
|
|
serverId: id,
|
|
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
|
|
});
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
|
await withOperation('stop', () => serializeControl(async () => {
|
|
await stopSingbox();
|
|
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
|
|
}));
|
|
return sendState(res, { singboxRunning: false });
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
|
await withOperation('start', () => serializeControl(async () => {
|
|
if (!fs.existsSync(settings.configPath)) {
|
|
throw new HarborError('CONFIG_INVALID');
|
|
}
|
|
await singboxRuntime.restart();
|
|
updateStoredState((state) => ({
|
|
...state,
|
|
appliedServerId: state.selectedServerId,
|
|
connectionDesired: 'running',
|
|
appliedRouteRules: state.routeRules,
|
|
}));
|
|
}));
|
|
return sendState(res, { singboxRunning: true });
|
|
}
|
|
|
|
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
|
}
|
|
|
|
const mime = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.json': 'application/json; charset=utf-8',
|
|
};
|
|
|
|
function serveStatic(req, res) {
|
|
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
const filePath = path.resolve(settings.distDir, requested);
|
|
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
res.writeHead(403);
|
|
return res.end('Forbidden');
|
|
}
|
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
|
? filePath
|
|
: path.join(settings.distDir, 'index.html');
|
|
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
|
fs.createReadStream(finalPath).pipe(res);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
return req.url?.startsWith('/api/')
|
|
? await handleApi(req, res)
|
|
: serveStatic(req, res);
|
|
} catch (error) {
|
|
return sendError(res, error);
|
|
}
|
|
});
|
|
|
|
async function shutdown() {
|
|
clearInterval(subscriptionRefreshTimer);
|
|
clearInterval(gatewayDiscoveryTimer);
|
|
await serializeControl(() => singboxRuntime.shutdown());
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
|
|
await refreshGatewayAutoMode({ reconfigure: false })
|
|
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
|
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
|
try {
|
|
writeCurrentConfig();
|
|
} catch (error) {
|
|
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
|
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
|
|
await resetSavedSubscription({ stopRuntime: false });
|
|
}
|
|
}
|
|
await startSingbox()
|
|
.then(() => {
|
|
if (fs.existsSync(settings.configPath)) {
|
|
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
|
|
}
|
|
})
|
|
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
|
|
|
server.listen(settings.port, '0.0.0.0', () => {
|
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
|
});
|
|
|
|
subscriptionRefreshTimer = setInterval(() => {
|
|
if (!stateStore.read().subscriptionUrl) return;
|
|
refreshSavedSubscription()
|
|
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
|
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
|
subscriptionRefreshTimer.unref();
|
|
|
|
gatewayDiscoveryTimer = setInterval(() => {
|
|
refreshGatewayAutoMode()
|
|
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
|
|
}, GATEWAY_DISCOVERY_INTERVAL_MS);
|
|
gatewayDiscoveryTimer.unref();
|