Split gateway control and dataplane into separate services
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-11 17:52:48 +03:00
parent 4b326c5e99
commit b0b9da51b6
15 changed files with 479 additions and 117 deletions

View File

@@ -1,8 +1,8 @@
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 { createDataplaneClient } from './dataplaneClient.js';
import { settings } from './config.js';
import {
applyGatewayPreference,
@@ -13,7 +13,7 @@ import {
readHostNetworkState,
sameGatewayRoute,
} from './gatewayPresence.js';
import { setGatewayInterception } from './gatewayRouting.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { tcpPing } from './ping.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
import {
@@ -29,8 +29,14 @@ const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
fs.mkdirSync(settings.dataDir, { recursive: true });
let singboxProcess = null;
let singboxStartedAt = null;
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
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;
@@ -110,79 +116,11 @@ function buildActiveConfig(subscriptionConfig, selectedTag) {
});
}
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());
}
}
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
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() {
async function publicState() {
await singboxRuntime.refresh();
const state = readJson(settings.statePath, {});
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
return {
@@ -190,8 +128,8 @@ function publicState() {
port: settings.port,
proxyPort: settings.proxyPort,
configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess),
singboxStartedAt,
singboxRunning: singboxRuntime.running,
singboxStartedAt: singboxRuntime.startedAt,
subscriptionHost: subscriptionHost(state.subscriptionUrl),
hasSubscription: Boolean(state.subscriptionUrl),
selectedTag: state.selectedTag || '',
@@ -230,7 +168,7 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = Boolean(singboxProcess);
const wasRunning = singboxRuntime.running;
try {
const configured = writeCurrentConfig();
if (reconfigure && configured && wasRunning) await startSingbox();
@@ -388,9 +326,9 @@ function refreshSavedSubscription() {
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
try {
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
if (singboxRuntime.running && activeConfigChanged) await applySelectedServer(selectedTag);
else if (selectedTag) {
if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
} else {
removeSingboxConfig();
}
@@ -428,14 +366,14 @@ function refreshSavedSubscription() {
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, publicState());
return sendJson(res, 200, await publicState());
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
return sendJson(res, 200, buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: Boolean(singboxProcess),
running: (await singboxRuntime.refresh()).running,
hostHeader: req.headers.host,
sharedProxyHost: settings.sharedProxyHost,
}));
@@ -508,7 +446,7 @@ async function handleApi(req, res) {
});
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
});
return sendJson(res, 200, { success: true, gatewayAuto: publicState().gatewayAuto });
return sendJson(res, 200, { success: true, gatewayAuto: (await publicState()).gatewayAuto });
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
@@ -542,7 +480,7 @@ async function handleApi(req, res) {
error.statusCode = 400;
throw error;
}
await startSingbox();
await singboxRuntime.restart();
});
return sendJson(res, 200, { success: true, singboxRunning: true });
}
@@ -591,7 +529,7 @@ const server = http.createServer(async (req, res) => {
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
clearInterval(gatewayDiscoveryTimer);
await serializeControl(() => stopSingbox());
await serializeControl(() => singboxRuntime.shutdown());
process.exit(0);
}