#!/usr/bin/env bash set -euo pipefail readonly RC_VERSION='1.14.0-rc.5' readonly ROLLBACK_VERSION='1.13.18' TEMP_DIR="$(mktemp -d)" SUFFIX="${TEMP_DIR##*/}" readonly SUFFIX="${SUFFIX//[^[:alnum:]]/}" readonly IMAGE="harbor-gateway-native-test:${RC_VERSION}-${SUFFIX}" readonly ROLLBACK_IMAGE="harbor-gateway-rollback-test:${ROLLBACK_VERSION}-${SUFFIX}" readonly CONTAINER="harbor-gateway-native-${SUFFIX}" cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true docker image rm "$IMAGE" "$ROLLBACK_IMAGE" >/dev/null 2>&1 || true rm -rf "$TEMP_DIR" } trap cleanup EXIT ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" docker build --build-arg "SINGBOX_VERSION=${RC_VERSION}" -t "$IMAGE" -f "$ROOT/Dockerfile" "$ROOT" docker build --build-arg "SINGBOX_VERSION=${ROLLBACK_VERSION}" -t "$ROLLBACK_IMAGE" -f "$ROOT/Dockerfile" "$ROOT" docker run --rm \ -e APP_MODE=gateway \ -e APP_COMPONENT=control \ -e DATAPLANE_SOCKET=/tmp/dataplane.sock \ -e SING_BOX_TRAFFIC_SOURCE=snapshot \ --entrypoint node \ "$ROLLBACK_IMAGE" --input-type=module --eval ' import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { buildGatewayConfig } from "/app/dist/server/singbox.js"; const subscription = { outbounds: [{ type: "vless", tag: "vpn", server: "vpn.example.test", server_port: 443, uuid: "00000000-0000-4000-8000-000000000000", tls: { enabled: true } }] }; const config = buildGatewayConfig(subscription, "vpn"); assert.equal(config.services, undefined); assert.deepEqual(config.dns, { independent_cache: true }); fs.writeFileSync("/tmp/rollback.json", JSON.stringify(config)); const checked = spawnSync("sing-box", ["check", "-c", "/tmp/rollback.json"], { encoding: "utf8" }); assert.equal(checked.status, 0, checked.stderr || checked.stdout); assert.match(spawnSync("sing-box", ["version"], { encoding: "utf8" }).stdout, /^sing-box version 1\.13\.18$/m); console.log("PASS Gateway snapshot rollback 1.13.18"); ' docker create -i --name "$CONTAINER" \ -e APP_MODE=gateway \ -e APP_COMPONENT=dataplane \ -e DATAPLANE_SOCKET=/tmp/dataplane.sock \ -e SING_BOX_TRAFFIC_SOURCE=native \ -e DATA_DIR=/tmp/harbor-gateway-native \ -e SING_BOX_CACHE=/tmp/harbor-gateway-native/cache.db \ --entrypoint /bin/bash "$IMAGE" -s >/dev/null docker start -a -i "$CONTAINER" <<'CONTAINER_SCRIPT' set -euo pipefail cd /app node --input-type=module <<'NODE' import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import { once } from 'node:events'; import { createClient } from '@connectrpc/connect'; import { createGrpcTransport } from '@connectrpc/connect-node'; import { StartedService } from '/app/dist/server/generated/daemon/started_service_pb.js'; import { materializeGatewayNativeConfig } from '/app/dist/server/gatewayNativeRuntime.js'; import { buildDualChannelGatewayConfig, buildGatewayConfig } from '/app/dist/server/singbox.js'; const directory = '/tmp/harbor-gateway-native'; const secretPath = `${directory}/api.secret`; const runtimePath = `${directory}/runtime-config.json`; const subscription = { outbounds: [{ type: 'vless', tag: 'vpn', server: 'vpn.example.test', server_port: 443, uuid: '00000000-0000-4000-8000-000000000000', tls: { enabled: true }, }] }; const single = buildGatewayConfig(subscription, 'vpn'); const dual = buildDualChannelGatewayConfig({ primary: { subscriptionConfig: subscription, selectedServerId: 'vpn' }, reserve: { subscriptionConfig: subscription, selectedServerId: 'vpn' }, }); let secret = ''; for (const config of [single, dual]) { assert.equal(JSON.stringify(config).includes('secret'), false); const materialized = materializeGatewayNativeConfig(config, { apiPort: 19091, secretPath, runtimeConfigPath: runtimePath, }); assert.equal(materialized.warning, null); secret ||= materialized.secret; assert.equal(materialized.secret, secret); assert.equal(fs.statSync(secretPath).mode & 0o777, 0o600); assert.equal(fs.statSync(runtimePath).mode & 0o777, 0o600); const checked = spawnSync('sing-box', ['check', '-c', runtimePath], { encoding: 'utf8' }); assert.equal(checked.status, 0, checked.stderr || checked.stdout); } materializeGatewayNativeConfig(single, { apiPort: 19091, secretPath, runtimeConfigPath: runtimePath }); const process = spawn('sing-box', ['run', '-c', runtimePath], { stdio: ['ignore', 'pipe', 'pipe'] }); let log = ''; process.stdout.on('data', (chunk) => { log += chunk; }); process.stderr.on('data', (chunk) => { log += chunk; }); const client = createClient(StartedService, createGrpcTransport({ baseUrl: 'http://127.0.0.1:19091' })); const controller = new AbortController(); const options = { signal: controller.signal, headers: { authorization: `Bearer ${secret}` } }; try { let version; for (let attempt = 0; attempt < 50; attempt += 1) { try { version = await client.getVersion({}, { ...options, timeoutMs: 200 }); break; } catch { await new Promise((resolve) => setTimeout(resolve, 100)); } } assert.ok(version, `native API did not start\n${log}`); assert.equal(version.version, '1.14.0-rc.5'); assert.ok((await client.getStartedAt({}, options)).startedAt > 0n); const connectionStream = client.subscribeConnections({ interval: 100_000_000n }, options)[Symbol.asyncIterator](); const statusStream = client.subscribeStatus({ interval: 100_000_000n }, options)[Symbol.asyncIterator](); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('native streams timed out')), 5_000)); assert.equal((await Promise.race([connectionStream.next(), timeout])).done, false); assert.equal((await Promise.race([statusStream.next(), timeout])).done, false); console.log('PASS Gateway RC5 single/dual config, 0600 secret and authenticated lifecycle API'); } finally { controller.abort(); process.kill('SIGTERM'); await Promise.race([once(process, 'exit'), new Promise((resolve) => setTimeout(resolve, 1_000))]); } NODE CONTAINER_SCRIPT