Files
harbor-net/src/server/index.js
Dmitriy Petrov 0ab912c64c
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
Add gateway auto toggle for client mode
2026-07-11 15:17:07 +03:00

624 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { isDeepStrictEqual } from 'node:util';
import { settings } from './config.js';
import {
applyGatewayPreference,
buildGatewayPresence,
createGatewayAutoState,
nextGatewayAutoState,
probeGatewayPresence,
readHostNetworkState,
sameGatewayRoute,
} from './gatewayPresence.js';
import { setGatewayInterception } from './gatewayRouting.js';
import { tcpPing } from './ping.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
import {
buildGatewayConfig,
removeSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
fs.mkdirSync(settings.dataDir, { recursive: true });
let singboxProcess = null;
let singboxStartedAt = null;
let subscriptionRefreshPromise = null;
let subscriptionRefreshTimer = null;
let gatewayDiscoveryPromise = null;
let gatewayDiscoveryTimer = null;
let gatewayAutoState = createGatewayAutoState();
let controlOperation = Promise.resolve();
function readJson(filePath, fallback) {
try {
return fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
: fallback;
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
}
function serializeControl(operation) {
const result = controlOperation.then(operation, operation);
controlOperation = result.catch(() => {});
return result;
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(payload));
}
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;
const error = new Error('Тело запроса слишком большое');
error.statusCode = 413;
reject(error);
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 {
const error = new Error('Невалидный JSON в теле запроса');
error.statusCode = 400;
reject(error);
}
});
req.on('error', reject);
});
}
function subscriptionHost(url) {
try {
return `${new URL(url).host}/…`;
} catch {
return '';
}
}
function buildActiveConfig(subscriptionConfig, selectedTag) {
return buildGatewayConfig(subscriptionConfig, selectedTag, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
});
}
function checkSingboxConfig() {
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
}
}
function stopSingbox() {
return new Promise((resolve) => {
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
if (!singboxProcess) {
singboxStartedAt = null;
return resolve();
}
const current = singboxProcess;
singboxProcess = null;
singboxStartedAt = null;
const timeout = setTimeout(() => {
current.kill('SIGKILL');
resolve();
}, 4000);
current.once('exit', () => {
clearTimeout(timeout);
resolve();
});
current.kill('SIGTERM');
});
}
async function startSingbox() {
if (!fs.existsSync(settings.configPath)) {
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
return false;
}
checkSingboxConfig();
await stopSingbox();
const child = spawn('sing-box', ['run', '-c', settings.configPath], {
stdio: ['ignore', 'inherit', 'inherit'],
});
singboxProcess = child;
singboxStartedAt = new Date().toISOString();
try {
if (settings.appMode === 'gateway') {
setGatewayInterception(true, settings.tproxyChain);
}
} catch (error) {
child.kill('SIGTERM');
singboxProcess = null;
singboxStartedAt = null;
throw error;
}
child.once('exit', () => {
if (singboxProcess === child) {
singboxProcess = null;
singboxStartedAt = null;
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
}
});
return true;
}
function publicState() {
const state = readJson(settings.statePath, {});
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
return {
mode: settings.appMode,
port: settings.port,
proxyPort: settings.proxyPort,
configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess),
singboxStartedAt,
subscriptionHost: subscriptionHost(state.subscriptionUrl),
hasSubscription: Boolean(state.subscriptionUrl),
selectedTag: state.selectedTag || '',
userInfo: state.userInfo || {},
fetchedAt: state.fetchedAt || null,
gatewayAuto: settings.appMode === 'client' ? {
mode: gatewayAutoState.mode,
enabled: gatewayAutoEnabled,
available: Boolean(gatewayAutoState.gatewayId),
address: gatewayAutoState.gateway?.gateway || '',
interface: gatewayAutoState.gateway?.interface || '',
failures: gatewayAutoState.failures,
lastError: gatewayAutoState.lastError,
} : null,
servers: (state.servers || []).map((server) => ({
...server,
tag: String(server.tag || '').trim(),
})),
};
}
function writeCurrentConfig() {
const state = readJson(settings.statePath, {});
const cached = readJson(settings.subscriptionCachePath, null);
if (!state.selectedTag || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
return true;
}
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
const previousState = gatewayAutoState;
const modeChanged = previousState.mode !== nextState.mode;
gatewayAutoState = nextState;
if (!modeChanged) return;
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = Boolean(singboxProcess);
try {
const configured = writeCurrentConfig();
if (reconfigure && configured && wasRunning) await startSingbox();
} catch (error) {
gatewayAutoState = previousState;
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
throw error;
}
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 = readJson(settings.statePath, {});
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 = readJson(settings.statePath, {});
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 = readJson(settings.statePath, {});
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(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) throw new Error('Сначала загрузите подписку');
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
try {
await startSingbox();
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
throw error;
}
writeJson(settings.statePath, {
...readJson(settings.statePath, {}),
selectedTag,
appliedAt: new Date().toISOString(),
});
}
function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
const initialState = readJson(settings.statePath, {});
if (!initialState.subscriptionUrl) {
const error = new Error('Подписка не настроена');
error.statusCode = 400;
throw error;
}
const subscriptionUrl = initialState.subscriptionUrl;
const parsed = await fetchSubscription(subscriptionUrl);
return serializeControl(async () => {
const currentState = readJson(settings.statePath, {});
if (currentState.subscriptionUrl !== subscriptionUrl) {
throw new Error('Подписка была изменена во время обновления');
}
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
const previousCache = readJson(settings.subscriptionCachePath, null);
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
!previousCache?.config || !isDeepStrictEqual(
buildActiveConfig(previousCache.config, currentState.selectedTag),
buildActiveConfig(parsed.config, selectedTag),
)
);
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
try {
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
else if (selectedTag) {
if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
} else {
removeSingboxConfig();
}
} catch (error) {
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
else fs.rmSync(settings.subscriptionCachePath, { force: true });
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
throw error;
}
writeJson(settings.statePath, {
...readJson(settings.statePath, {}),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedTag,
});
return {
success: true,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedTag,
};
});
})().finally(() => {
subscriptionRefreshPromise = null;
});
return subscriptionRefreshPromise;
}
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, publicState());
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
return sendJson(res, 200, buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: Boolean(singboxProcess),
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 = readJson(settings.statePath, {});
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 = readJson(settings.statePath, {});
const results = await Promise.all((state.servers || []).map(async (server) => ({
tag: String(server.tag || '').trim(),
...await tcpPing(server.server, server.server_port),
checkedAt: new Date().toISOString(),
})));
return sendJson(res, 200, { success: true, results });
}
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim();
const parsed = await fetchSubscription(normalizedUrl);
await serializeControl(async () => {
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
const previousState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
subscriptionUrl: normalizedUrl,
gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
await stopSingbox();
removeSingboxConfig();
gatewayAutoState = createGatewayAutoState();
});
return sendJson(res, 200, { success: true, ...parsed });
}
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
const { url = '' } = await readBody(req);
const parsed = await fetchSubscription(String(url).trim());
return sendJson(res, 200, { success: true, servers: parsed.servers.length });
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
return sendJson(res, 200, await refreshSavedSubscription());
}
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
if (settings.appMode !== 'client') {
return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' });
}
const { enabled } = await readBody(req);
if (typeof enabled !== 'boolean') {
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
}
await serializeControl(async () => {
writeJson(settings.statePath, {
...readJson(settings.statePath, {}),
gatewayAutoEnabled: enabled,
});
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
});
return sendJson(res, 200, { success: true, gatewayAuto: publicState().gatewayAuto });
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
await serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
fs.rmSync(settings.subscriptionCachePath, { force: true });
writeJson(settings.statePath, {});
gatewayAutoState = createGatewayAutoState();
});
return sendJson(res, 200, { success: true });
}
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: 'Выберите сервер' });
await serializeControl(() => applySelectedServer(tag));
return sendJson(res, 200, { success: true, selectedTag: tag });
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
await serializeControl(() => stopSingbox());
return sendJson(res, 200, { success: true, singboxRunning: false });
}
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
await serializeControl(async () => {
if (!fs.existsSync(settings.configPath)) {
const error = new Error('Сначала выберите сервер');
error.statusCode = 400;
throw error;
}
await startSingbox();
});
return sendJson(res, 200, { success: true, singboxRunning: true });
}
return sendJson(res, 404, { success: false, error: 'Не найдено' });
}
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) {
console.error('[control] request failed', error);
return sendJson(res, error.statusCode || 500, {
success: false,
error: error.message || String(error),
});
}
});
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
clearInterval(gatewayDiscoveryTimer);
await serializeControl(() => stopSingbox());
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)) {
writeCurrentConfig();
}
await startSingbox().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 (!readJson(settings.statePath, {}).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();