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