Add Harbor Gateway auto-detection for client routing
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 15:02:33 +03:00
parent 0a1aa8aed3
commit 51312d51cd
17 changed files with 717 additions and 165 deletions

View File

@@ -4,6 +4,14 @@ import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { isDeepStrictEqual } from 'node:util';
import { settings } from './config.js';
import {
buildGatewayPresence,
createGatewayAutoState,
nextGatewayAutoState,
probeGatewayPresence,
readHostNetworkState,
sameGatewayRoute,
} from './gatewayPresence.js';
import { setGatewayInterception } from './gatewayRouting.js';
import { tcpPing } from './ping.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
@@ -12,10 +20,11 @@ import {
removeSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import { fetchSubscription, selectRefreshedServer } from './subscription.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 });
@@ -23,6 +32,10 @@ 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 {
@@ -39,6 +52,12 @@ function writeJson(filePath, value) {
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));
@@ -84,6 +103,12 @@ function subscriptionHost(url) {
}
}
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',
@@ -170,6 +195,13 @@ function publicState() {
selectedTag: state.selectedTag || '',
userInfo: state.userInfo || {},
fetchedAt: state.fetchedAt || null,
gatewayAuto: settings.appMode === 'client' ? {
mode: gatewayAutoState.mode,
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(),
@@ -177,6 +209,120 @@ function publicState() {
};
}
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(
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
{ 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(
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
{ reconfigure },
);
}
return gatewayAutoState;
}).finally(() => {
gatewayDiscoveryPromise = null;
});
return gatewayDiscoveryPromise;
}
async function applySelectedServer(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) throw new Error('Сначала загрузите подписку');
@@ -184,7 +330,7 @@ async function applySelectedServer(selectedTag) {
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag));
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
try {
await startSingbox();
} catch (error) {
@@ -212,55 +358,57 @@ function refreshSavedSubscription() {
const subscriptionUrl = initialState.subscriptionUrl;
const parsed = await fetchSubscription(subscriptionUrl);
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(
buildGatewayConfig(previousCache.config, currentState.selectedTag),
buildGatewayConfig(parsed.config, selectedTag),
)
);
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
try {
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
else if (selectedTag) {
if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
} else {
removeSingboxConfig();
return serializeControl(async () => {
const currentState = readJson(settings.statePath, {});
if (currentState.subscriptionUrl !== subscriptionUrl) {
throw new Error('Подписка была изменена во время обновления');
}
} 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,
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,
};
});
return {
success: true,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedTag,
};
})().finally(() => {
subscriptionRefreshPromise = null;
});
@@ -283,6 +431,17 @@ async function handleApi(req, res) {
}));
}
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) => ({
@@ -297,15 +456,18 @@ async function handleApi(req, res) {
const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim();
const parsed = await fetchSubscription(normalizedUrl);
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
writeJson(settings.statePath, {
subscriptionUrl: normalizedUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
await serializeControl(async () => {
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
writeJson(settings.statePath, {
subscriptionUrl: normalizedUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
await stopSingbox();
removeSingboxConfig();
gatewayAutoState = createGatewayAutoState();
});
await stopSingbox();
removeSingboxConfig();
return sendJson(res, 200, { success: true, ...parsed });
}
@@ -320,10 +482,13 @@ async function handleApi(req, res) {
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
await stopSingbox();
removeSingboxConfig();
fs.rmSync(settings.subscriptionCachePath, { force: true });
writeJson(settings.statePath, {});
await serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
fs.rmSync(settings.subscriptionCachePath, { force: true });
writeJson(settings.statePath, {});
gatewayAutoState = createGatewayAutoState();
});
return sendJson(res, 200, { success: true });
}
@@ -331,20 +496,24 @@ async function handleApi(req, res) {
const { selectedTag = '' } = await readBody(req);
const tag = String(selectedTag).trim();
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
await applySelectedServer(tag);
await serializeControl(() => applySelectedServer(tag));
return sendJson(res, 200, { success: true, selectedTag: tag });
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
await stopSingbox();
await serializeControl(() => stopSingbox());
return sendJson(res, 200, { success: true, singboxRunning: false });
}
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
if (!fs.existsSync(settings.configPath)) {
return sendJson(res, 400, { success: false, error: 'Сначала выберите сервер' });
}
await startSingbox();
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 });
}
@@ -391,17 +560,18 @@ const server = http.createServer(async (req, res) => {
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
await stopSingbox();
clearInterval(gatewayDiscoveryTimer);
await serializeControl(() => stopSingbox());
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
const state = readJson(settings.statePath, {});
const cached = readJson(settings.subscriptionCachePath, null);
if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) {
writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag));
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}`));
@@ -415,3 +585,9 @@ subscriptionRefreshTimer = setInterval(() => {
.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();