624 lines
21 KiB
JavaScript
624 lines
21 KiB
JavaScript
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();
|