From b0b9da51b6a8dd0951318bee1cf8cbb3dedf954d Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 17:52:48 +0300 Subject: [PATCH] Split gateway control and dataplane into separate services --- .gitea/workflows/gateway-build.yml | 28 +++++-- README.md | 6 +- docker-compose.gateway.yml | 57 ++++++++++--- entrypoint.sh | 11 ++- scripts/build-on-107-deploy-111.sh | 4 +- scripts/deploy-gateway.sh | 80 ++++++++++++++++--- src/server/config.js | 1 + src/server/dataplane.js | 70 ++++++++++++++++ src/server/dataplaneClient.js | 43 ++++++++++ src/server/index.js | 110 ++++++-------------------- src/server/singboxRuntime.js | 88 +++++++++++++++++++++ test/server/dataplane-client.test.js | 23 ++++++ test/server/deploy-split.test.js | 17 ++++ test/server/entrypoint-tproxy.test.js | 7 ++ test/server/singbox-runtime.test.js | 51 ++++++++++++ 15 files changed, 479 insertions(+), 117 deletions(-) create mode 100644 src/server/dataplane.js create mode 100644 src/server/dataplaneClient.js create mode 100644 src/server/singboxRuntime.js create mode 100644 test/server/dataplane-client.test.js create mode 100644 test/server/deploy-split.test.js create mode 100644 test/server/singbox-runtime.test.js diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index 6ae77c6..d834784 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -35,6 +35,8 @@ jobs: REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" + CONTROL_IMAGE="${IMAGE}-control" + DATAPLANE_IMAGE="${IMAGE}-dataplane" echo "Build runner: $(hostname)" echo "Base image: ${{ env.BASE_IMAGE }}" @@ -73,11 +75,15 @@ jobs: --build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \ --build-arg INSTALL_RUNTIME_DEPS=false \ --build-arg INSTALL_SINGBOX=false \ - -t "${IMAGE}:latest" \ - -t "${IMAGE}:${{ gitea.sha }}" \ + -t "${CONTROL_IMAGE}:latest" \ + -t "${CONTROL_IMAGE}:${{ gitea.sha }}" \ + -t "${DATAPLANE_IMAGE}:latest" \ + -t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \ . - docker push "${IMAGE}:latest" - docker push "${IMAGE}:${{ gitea.sha }}" + docker push "${CONTROL_IMAGE}:latest" + docker push "${CONTROL_IMAGE}:${{ gitea.sha }}" + docker push "${DATAPLANE_IMAGE}:latest" + docker push "${DATAPLANE_IMAGE}:${{ gitea.sha }}" deploy: runs-on: lxc-111 @@ -101,7 +107,19 @@ jobs: REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" + CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}" + DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}" + UPDATE_DATAPLANE=false + if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \ + '^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|dataplaneClient|gatewayRouting|singboxRuntime)\.js)$'; then + UPDATE_DATAPLANE=true + fi echo "Deploy runner: $(hostname)" + echo "Update dataplane: ${UPDATE_DATAPLANE}" echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin - DEPLOY_PATH="${{ env.DEPLOY_PATH }}" GATEWAY_IMAGE="${IMAGE}:${{ gitea.sha }}" bash scripts/deploy-gateway.sh + DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \ + CONTROL_IMAGE="${CONTROL_IMAGE}" \ + DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \ + UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \ + bash scripts/deploy-gateway.sh diff --git a/README.md b/README.md index 4500669..eaca8e4 100644 --- a/README.md +++ b/README.md @@ -168,9 +168,11 @@ Harbor Connect раз в пять секунд узнаёт у macOS адрес | Действие | Команда | | --- | --- | | Запустить или обновить после изменения файлов | `docker compose -f docker-compose.gateway.yml up -d --build` | +| Обновить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml build vpn-proxy-control && docker compose -f docker-compose.gateway.yml up -d --no-deps vpn-proxy-control` | | Показать состояние | `docker compose -f docker-compose.gateway.yml ps` | | Смотреть журнал | `docker compose -f docker-compose.gateway.yml logs -f` | -| Перезапустить | `docker compose -f docker-compose.gateway.yml restart` | +| Перезапустить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-control` | +| Перезапустить VPN dataplane | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-dataplane` | | Остановить | `docker compose -f docker-compose.gateway.yml down` | | Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.gateway.yml down -v` | @@ -288,7 +290,7 @@ docker compose -f docker-compose.client.yml config | `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` | | `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent | -Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. +Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Обычный деплой пересоздаёт только `vpn-proxy-control`; процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`. Dataplane обновляется отдельно, только когда изменены его runtime-файлы. ## Хранение данных diff --git a/docker-compose.gateway.yml b/docker-compose.gateway.yml index 53b4209..58af7cf 100644 --- a/docker-compose.gateway.yml +++ b/docker-compose.gateway.yml @@ -1,14 +1,18 @@ +x-gateway-image: &gateway-image + image: ${GATEWAY_IMAGE:-vpn-proxy-gateway:local} + build: + context: . + dockerfile: Dockerfile + args: + BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim} + SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13} + INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true} + INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true} + services: - vpn-proxy-gateway: - build: - context: . - dockerfile: Dockerfile - args: - BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim} - SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13} - INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true} - INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true} - container_name: vpn-proxy-gateway + vpn-proxy-dataplane: + <<: *gateway-image + container_name: vpn-proxy-dataplane network_mode: host cap_add: - NET_ADMIN @@ -17,11 +21,43 @@ services: - path: .env required: false environment: + APP_COMPONENT: dataplane DATA_DIR: /var/lib/vpn-proxy + SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db + DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock volumes: - vpn-proxy-data:/var/lib/vpn-proxy - sing-box-cache:/var/lib/sing-box + - vpn-proxy-runtime:/run/vpn-proxy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + + vpn-proxy-control: + <<: *gateway-image + container_name: vpn-proxy-gateway + env_file: + - path: .env + required: false + environment: + APP_COMPONENT: control + DATA_DIR: /var/lib/vpn-proxy + SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json + SING_BOX_CACHE: /var/lib/sing-box/cache.db + DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock + ports: + - "${PORT:-3456}:${PORT:-3456}" + volumes: + - vpn-proxy-data:/var/lib/vpn-proxy + - vpn-proxy-runtime:/run/vpn-proxy + depends_on: + vpn-proxy-dataplane: + condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"] @@ -33,3 +69,4 @@ services: volumes: vpn-proxy-data: sing-box-cache: + vpn-proxy-runtime: diff --git a/entrypoint.sh b/entrypoint.sh index 0789fc5..59c6364 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +APP_COMPONENT="${APP_COMPONENT:-combined}" TPROXY_PORT="${TPROXY_PORT:-7895}" TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" @@ -19,6 +20,10 @@ log() { printf '[gateway-entrypoint] %s\n' "$*" } +if [[ "$APP_COMPONENT" == "control" ]]; then + exec node /app/src/server/index.js +fi + ipt() { iptables -w "$@" } @@ -110,7 +115,11 @@ setup_gateway_forwarding setup_tproxy setup_proxy_firewall -node /app/src/server/index.js & +if [[ "$APP_COMPONENT" == "dataplane" ]]; then + node /app/src/server/dataplane.js & +else + node /app/src/server/index.js & +fi APP_PID=$! shutdown() { diff --git a/scripts/build-on-107-deploy-111.sh b/scripts/build-on-107-deploy-111.sh index ebbe678..f6fcca8 100755 --- a/scripts/build-on-107-deploy-111.sh +++ b/scripts/build-on-107-deploy-111.sh @@ -104,9 +104,9 @@ echo "Starting gateway on ${DEPLOY_HOST}" if [ "${DEPLOY_HOST}" = "local" ]; then cd "${DEPLOY_PATH}" chmod +x ./deploy-gateway.sh - DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" PULL_IMAGE=false ./deploy-gateway.sh + DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh else ensure_known_host "${DEPLOY_HOST}" ssh_cmd "${DEPLOY_HOST}" \ - "cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' PULL_IMAGE=false ./deploy-gateway.sh" + "cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh" fi diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 5449754..347b227 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -2,17 +2,29 @@ set -euo pipefail DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}" -GATEWAY_IMAGE="${GATEWAY_IMAGE:?GATEWAY_IMAGE is required}" +CONTROL_IMAGE="${CONTROL_IMAGE:-${GATEWAY_IMAGE:-}}" +DATAPLANE_IMAGE="${DATAPLANE_IMAGE:-${GATEWAY_IMAGE:-}}" +CONTROL_IMAGE="${CONTROL_IMAGE:?CONTROL_IMAGE or GATEWAY_IMAGE is required}" +DATAPLANE_IMAGE="${DATAPLANE_IMAGE:?DATAPLANE_IMAGE or GATEWAY_IMAGE is required}" +UPDATE_DATAPLANE="${UPDATE_DATAPLANE:-false}" PULL_IMAGE="${PULL_IMAGE:-true}" echo "Preparing deploy directory: ${DEPLOY_PATH}" mkdir -p "${DEPLOY_PATH}" +EXISTING_DATAPLANE_IMAGE="$(docker inspect --format '{{.Config.Image}}' vpn-proxy-dataplane 2>/dev/null || true)" +FIRST_SPLIT_DEPLOY=false +if [ -z "${EXISTING_DATAPLANE_IMAGE}" ]; then + FIRST_SPLIT_DEPLOY=true +elif [ "${UPDATE_DATAPLANE}" != "true" ]; then + DATAPLANE_IMAGE="${EXISTING_DATAPLANE_IMAGE}" +fi + cat > "${DEPLOY_PATH}/docker-compose.server.yml" </dev/null || true + docker rm vpn-proxy-gateway 2>/dev/null || true + docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90 +elif [ "${UPDATE_DATAPLANE}" = "true" ]; then + echo "Updating control and dataplane..." + docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90 +else + echo "Updating control; keeping dataplane running..." + docker compose -f docker-compose.server.yml up -d --no-deps --wait --wait-timeout 90 vpn-proxy-control +fi -echo "Current container:" -docker ps --filter "name=vpn-proxy-gateway" +echo "Current containers:" +docker ps --filter "name=vpn-proxy-gateway" --filter "name=vpn-proxy-dataplane" diff --git a/src/server/config.js b/src/server/config.js index 35a9668..3130e75 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -16,6 +16,7 @@ export const settings = { proxyPort, tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", + dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, distDir: process.env.DIST_DIR || "/app/dist", diff --git a/src/server/dataplane.js b/src/server/dataplane.js new file mode 100644 index 0000000..9530bed --- /dev/null +++ b/src/server/dataplane.js @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { settings } from './config.js'; +import { createSingboxRuntime } from './singboxRuntime.js'; + +const socketPath = settings.dataplaneSocket; +const runtime = createSingboxRuntime({ + configPath: settings.configPath, + gateway: true, + tproxyChain: settings.tproxyChain, +}); +let ready = false; + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +} + +const server = http.createServer(async (req, res) => { + try { + if (req.method === 'GET' && req.url === '/status') { + return sendJson(res, ready ? 200 : 503, { + ...await runtime.refresh(), + ready, + }); + } + if (req.method === 'POST' && req.url === '/apply') { + return sendJson(res, 200, await runtime.apply()); + } + if (req.method === 'POST' && req.url === '/restart') { + return sendJson(res, 200, await runtime.restart()); + } + if (req.method === 'POST' && req.url === '/stop') { + return sendJson(res, 200, await runtime.stop()); + } + return sendJson(res, 404, { error: 'Не найдено' }); + } catch (error) { + return sendJson(res, 500, { error: error.message || String(error) }); + } +}); + +fs.mkdirSync(path.dirname(socketPath), { recursive: true }); +fs.rmSync(socketPath, { force: true }); +server.listen(socketPath, async () => { + fs.chmodSync(socketPath, 0o660); + try { + await runtime.apply(); + } catch (error) { + console.warn(`[dataplane] sing-box не запущен: ${error.message}`); + } finally { + ready = true; + console.log(`[dataplane] control socket: ${socketPath}`); + } +}); + +let shuttingDown = false; +async function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + ready = false; + await runtime.shutdown(); + server.close(() => { + fs.rmSync(socketPath, { force: true }); + process.exit(0); + }); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js new file mode 100644 index 0000000..7499fba --- /dev/null +++ b/src/server/dataplaneClient.js @@ -0,0 +1,43 @@ +import http from 'node:http'; + +function request(socketPath, pathname, method = 'GET') { + return new Promise((resolve, reject) => { + const req = http.request({ socketPath, path: pathname, method }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + let body = {}; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + } catch { + return reject(new Error('Dataplane вернул невалидный JSON')); + } + if ((res.statusCode || 500) >= 400) { + return reject(new Error(body.error || `Dataplane HTTP ${res.statusCode}`)); + } + resolve(body); + }); + }); + req.on('error', reject); + req.setTimeout(6000, () => req.destroy(new Error('Dataplane не ответил за 6 секунд'))); + req.end(); + }); +} + +export function createDataplaneClient(socketPath, send = request) { + let current = { running: false, startedAt: null }; + const update = async (pathname, method) => { + current = await send(socketPath, pathname, method); + return current; + }; + + return { + get running() { return Boolean(current.running); }, + get startedAt() { return current.startedAt || null; }, + refresh: () => update('/status', 'GET'), + apply: () => update('/apply', 'POST'), + restart: () => update('/restart', 'POST'), + stop: () => update('/stop', 'POST'), + shutdown: async () => current, + }; +} diff --git a/src/server/index.js b/src/server/index.js index 09a4252..2dcbaa1 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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); } diff --git a/src/server/singboxRuntime.js b/src/server/singboxRuntime.js new file mode 100644 index 0000000..27b2c9a --- /dev/null +++ b/src/server/singboxRuntime.js @@ -0,0 +1,88 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { setGatewayInterception } from './gatewayRouting.js'; + +export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) { + let child = null; + let configHash = ''; + let startedAt = null; + + const state = () => ({ running: Boolean(child), startedAt }); + + async function stop() { + if (gateway) setGatewayInterception(false, tproxyChain); + if (!child) { + configHash = ''; + startedAt = null; + return state(); + } + + const current = child; + child = null; + configHash = ''; + startedAt = null; + await new Promise((resolve) => { + const timeout = setTimeout(() => { + current.kill('SIGKILL'); + resolve(); + }, 4000); + current.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + current.kill('SIGTERM'); + }); + return state(); + } + + async function apply({ force = false } = {}) { + if (!fs.existsSync(configPath)) { + await stop(); + return state(); + } + + const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); + if (check.status !== 0) { + throw new Error((check.stderr || check.stdout || 'sing-box check failed').trim()); + } + + const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); + if (!force && child && nextHash === configHash) return state(); + + await stop(); + const current = spawn('sing-box', ['run', '-c', configPath], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + child = current; + configHash = nextHash; + startedAt = new Date().toISOString(); + try { + if (gateway) setGatewayInterception(true, tproxyChain); + } catch (error) { + current.kill('SIGTERM'); + child = null; + configHash = ''; + startedAt = null; + throw error; + } + current.once('exit', () => { + if (child !== current) return; + child = null; + configHash = ''; + startedAt = null; + if (gateway) setGatewayInterception(false, tproxyChain); + }); + return state(); + } + + return { + get running() { return Boolean(child); }, + get startedAt() { return startedAt; }, + refresh: async () => state(), + apply, + restart: () => apply({ force: true }), + stop, + shutdown: stop, + }; +} diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js new file mode 100644 index 0000000..0acaa37 --- /dev/null +++ b/test/server/dataplane-client.test.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDataplaneClient } from '../../src/server/dataplaneClient.js'; + +test('control uses the dataplane socket protocol', async () => { + const requests = []; + const send = async (socketPath, pathname, method) => { + requests.push(`${method} ${pathname} ${socketPath}`); + return { running: pathname !== '/stop', startedAt: 'now' }; + }; + + const client = createDataplaneClient('/run/dataplane.sock', send); + assert.equal((await client.refresh()).running, true); + await client.apply(); + await client.restart(); + assert.equal((await client.stop()).running, false); + assert.deepEqual(requests, [ + 'GET /status /run/dataplane.sock', + 'POST /apply /run/dataplane.sock', + 'POST /restart /run/dataplane.sock', + 'POST /stop /run/dataplane.sock', + ]); +}); diff --git a/test/server/deploy-split.test.js b/test/server/deploy-split.test.js new file mode 100644 index 0000000..6f48520 --- /dev/null +++ b/test/server/deploy-split.test.js @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '../..'); +const compose = fs.readFileSync(path.join(root, 'docker-compose.gateway.yml'), 'utf8'); +const deploy = fs.readFileSync(path.join(root, 'scripts/deploy-gateway.sh'), 'utf8'); +const workflow = fs.readFileSync(path.join(root, '.gitea/workflows/gateway-build.yml'), 'utf8'); + +test('gateway deploy updates control without recreating dataplane', () => { + assert.match(compose, /vpn-proxy-control:/); + assert.match(compose, /vpn-proxy-dataplane:/); + assert.match(compose, /DATAPLANE_SOCKET: \/run\/vpn-proxy\/dataplane\.sock/); + assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/); + assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/); +}); diff --git a/test/server/entrypoint-tproxy.test.js b/test/server/entrypoint-tproxy.test.js index 77a6f77..66cb108 100644 --- a/test/server/entrypoint-tproxy.test.js +++ b/test/server/entrypoint-tproxy.test.js @@ -16,3 +16,10 @@ test('gateway keeps direct forwarding active while TProxy interception is switch assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/); assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/); }); + +test('control bypasses host routing while dataplane owns it', () => { + assert.match(entrypoint, /APP_COMPONENT.*control/); + assert.match(entrypoint, /exec node \/app\/src\/server\/index\.js/); + assert.match(entrypoint, /APP_COMPONENT.*dataplane/); + assert.match(entrypoint, /node \/app\/src\/server\/dataplane\.js/); +}); diff --git a/test/server/singbox-runtime.test.js b/test/server/singbox-runtime.test.js new file mode 100644 index 0000000..db86c0a --- /dev/null +++ b/test/server/singbox-runtime.test.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createSingboxRuntime } from '../../src/server/singboxRuntime.js'; + +async function waitForStarts(filePath, count) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8').length >= count) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`sing-box did not start ${count} time(s)`); +} + +test('dataplane keeps sing-box running when the applied config is unchanged', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-dataplane-')); + const binDir = path.join(dir, 'bin'); + const configPath = path.join(dir, 'config.json'); + const startsPath = path.join(dir, 'starts'); + fs.mkdirSync(binDir); + fs.writeFileSync(configPath, '{}'); + fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node +if (process.argv[2] === 'check') process.exit(0); +require('node:fs').appendFileSync(process.env.SINGBOX_TEST_STARTS, 'x'); +process.on('SIGTERM', () => process.exit(0)); +setInterval(() => {}, 60_000); +`); + fs.chmodSync(path.join(binDir, 'sing-box'), 0o755); + + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}:${previousPath}`; + process.env.SINGBOX_TEST_STARTS = startsPath; + const runtime = createSingboxRuntime({ configPath }); + t.after(async () => { + await runtime.stop(); + process.env.PATH = previousPath; + delete process.env.SINGBOX_TEST_STARTS; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + await runtime.apply(); + await waitForStarts(startsPath, 1); + await runtime.apply(); + assert.equal(fs.readFileSync(startsPath, 'utf8'), 'x'); + + fs.writeFileSync(configPath, '{"changed":true}'); + await runtime.apply(); + await waitForStarts(startsPath, 2); + assert.equal(fs.readFileSync(startsPath, 'utf8'), 'xx'); +});