Split gateway control and dataplane into separate services
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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-файлы.
|
||||
|
||||
## Хранение данных
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" <<EOF
|
||||
services:
|
||||
vpn-proxy-gateway:
|
||||
image: ${GATEWAY_IMAGE}
|
||||
container_name: vpn-proxy-gateway
|
||||
vpn-proxy-dataplane:
|
||||
image: ${DATAPLANE_IMAGE}
|
||||
container_name: vpn-proxy-dataplane
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -20,11 +32,42 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
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:
|
||||
image: ${CONTROL_IMAGE}
|
||||
container_name: vpn-proxy-gateway
|
||||
env_file:
|
||||
- .env
|
||||
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"]
|
||||
@@ -36,6 +79,7 @@ services:
|
||||
volumes:
|
||||
vpn-proxy-data:
|
||||
sing-box-cache:
|
||||
vpn-proxy-runtime:
|
||||
EOF
|
||||
|
||||
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
|
||||
@@ -59,15 +103,29 @@ fi
|
||||
|
||||
cd "${DEPLOY_PATH}"
|
||||
|
||||
echo "Pulling image: ${GATEWAY_IMAGE}"
|
||||
echo "Control image: ${CONTROL_IMAGE}"
|
||||
echo "Dataplane image: ${DATAPLANE_IMAGE}"
|
||||
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||
docker compose -f docker-compose.server.yml pull
|
||||
docker compose -f docker-compose.server.yml pull vpn-proxy-control
|
||||
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ] || [ "${UPDATE_DATAPLANE}" = "true" ]; then
|
||||
docker compose -f docker-compose.server.yml pull vpn-proxy-dataplane
|
||||
fi
|
||||
else
|
||||
echo "Skipping image pull; using local image ${GATEWAY_IMAGE}"
|
||||
echo "Skipping image pull"
|
||||
fi
|
||||
|
||||
echo "Starting gateway..."
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ]; then
|
||||
echo "Migrating the legacy combined gateway to split services..."
|
||||
docker stop vpn-proxy-gateway 2>/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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
70
src/server/dataplane.js
Normal file
70
src/server/dataplane.js
Normal file
@@ -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);
|
||||
43
src/server/dataplaneClient.js
Normal file
43
src/server/dataplaneClient.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
88
src/server/singboxRuntime.js
Normal file
88
src/server/singboxRuntime.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
23
test/server/dataplane-client.test.js
Normal file
23
test/server/dataplane-client.test.js
Normal file
@@ -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',
|
||||
]);
|
||||
});
|
||||
17
test/server/deploy-split.test.js
Normal file
17
test/server/deploy-split.test.js
Normal file
@@ -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\}"/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
51
test/server/singbox-runtime.test.js
Normal file
51
test/server/singbox-runtime.test.js
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user