#!/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-singbox-native-test:${RC_VERSION}-${SUFFIX}" readonly CONTAINER="harbor-singbox-native-${SUFFIX}" readonly ROLLBACK_IMAGE="harbor-singbox-rollback-test:${ROLLBACK_VERSION}-${SUFFIX}" readonly ROLLBACK_CONTAINER="harbor-singbox-rollback-${SUFFIX}" IMAGE_CREATED=false CONTAINER_CREATED=false ROLLBACK_IMAGE_CREATED=false ROLLBACK_CONTAINER_CREATED=false cleanup() { [[ "$ROLLBACK_CONTAINER_CREATED" == true ]] && docker rm -f "$ROLLBACK_CONTAINER" >/dev/null 2>&1 || true [[ "$CONTAINER_CREATED" == true ]] && docker rm -f "$CONTAINER" >/dev/null 2>&1 || true [[ "$ROLLBACK_IMAGE_CREATED" == true ]] && docker image rm "$ROLLBACK_IMAGE" >/dev/null 2>&1 || true [[ "$IMAGE_CREATED" == true ]] && docker image rm "$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.client" \ "$ROOT" IMAGE_CREATED=true docker build \ --build-arg "SINGBOX_VERSION=${ROLLBACK_VERSION}" \ -t "$ROLLBACK_IMAGE" \ -f "$ROOT/Dockerfile.client" \ "$ROOT" ROLLBACK_IMAGE_CREATED=true docker create -i \ --name "$ROLLBACK_CONTAINER" \ -e EXPECTED_SINGBOX_VERSION="$ROLLBACK_VERSION" \ -e APP_MODE=client \ -e PROXY_PORT=18081 \ -e DIAGNOSTICS_PROXY_PORT=18082 \ -e SING_BOX_TRAFFIC_SOURCE=disabled \ -e DATA_DIR=/tmp/harbor-rollback-test \ -e SING_BOX_CONFIG=/tmp/harbor-rollback-test/config.json \ -e SING_BOX_CACHE=/tmp/harbor-rollback-test/cache.db \ --entrypoint /bin/bash \ "$ROLLBACK_IMAGE" -s >/dev/null ROLLBACK_CONTAINER_CREATED=true docker start -a -i "$ROLLBACK_CONTAINER" <<'ROLLBACK_SCRIPT' set -euo pipefail cd /app node --input-type=module <<'NODE' 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'; import { parseSubscriptionBody } from '/app/dist/server/subscription.js'; const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS'; const parsed = parseSubscriptionBody(Buffer.from(websocket).toString('base64')); const config = buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect: true }); assert.equal(config.services, undefined); assert.deepEqual(config.dns, { independent_cache: true }); fs.mkdirSync('/tmp/harbor-rollback-test', { recursive: true }); fs.writeFileSync(process.env.SING_BOX_CONFIG, JSON.stringify(config)); const version = spawnSync('sing-box', ['version'], { encoding: 'utf8' }); assert.equal(version.status, 0, version.stderr); assert.equal(version.stdout.split('\n')[0], `sing-box version ${process.env.EXPECTED_SINGBOX_VERSION}`); const checked = spawnSync('sing-box', ['check', '-c', process.env.SING_BOX_CONFIG], { encoding: 'utf8' }); assert.equal(checked.status, 0, checked.stderr || checked.stdout); console.log(`PASS rollback config ${process.env.EXPECTED_SINGBOX_VERSION} services=absent dns.independent_cache=true`); NODE ROLLBACK_SCRIPT docker create -i \ --name "$CONTAINER" \ -e EXPECTED_SINGBOX_VERSION="$RC_VERSION" \ -e APP_MODE=client \ -e PROXY_PORT=18081 \ -e DIAGNOSTICS_PROXY_PORT=18082 \ -e SING_BOX_TRAFFIC_SOURCE=native \ -e DATA_DIR=/tmp/harbor-native-test \ -e SING_BOX_CONFIG=/tmp/harbor-native-test/config.json \ -e SING_BOX_CACHE=/tmp/harbor-native-test/cache.db \ --entrypoint /bin/bash \ "$IMAGE" -s >/dev/null CONTAINER_CREATED=true 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 { once } from 'node:events'; import fs from 'node:fs'; import http from 'node:http'; import { createClient } from '@connectrpc/connect'; import { createGrpcTransport } from '@connectrpc/connect-node'; import { ConnectionEventType, StartedService, } from '/app/dist/server/generated/daemon/started_service_pb.js'; import { buildGatewayConfig } from '/app/dist/server/singbox.js'; import { parseSubscriptionBody } from '/app/dist/server/subscription.js'; const API_PORT = 19091; const CONFIG_PATH = process.env.SING_BOX_CONFIG; const EXPECTED_VERSION = process.env.EXPECTED_SINGBOX_VERSION; const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS'; function timeout(promise, label, milliseconds = 10_000) { let timer; return Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), milliseconds); }), ]).finally(() => clearTimeout(timer)); } function deferred() { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function stop(child) { if (!child || child.exitCode !== null) return Promise.resolve(); child.kill('SIGTERM'); return Promise.race([once(child, 'exit'), new Promise((resolve) => setTimeout(resolve, 1_000))]); } const parsed = parseSubscriptionBody(Buffer.from(websocket).toString('base64')); const config = buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect: true }); assert.deepEqual(config.services, [{ type: 'api', listen: '127.0.0.1', listen_port: API_PORT, dashboard: false, }]); fs.mkdirSync(new URL('.', `file://${CONFIG_PATH}`).pathname, { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(config)); const version = spawnSync('sing-box', ['version'], { encoding: 'utf8' }); assert.equal(version.status, 0, version.stderr); assert.equal(version.stdout.split('\n')[0], `sing-box version ${EXPECTED_VERSION}`); const checked = spawnSync('sing-box', ['check', '-c', CONFIG_PATH], { encoding: 'utf8' }); assert.equal(checked.status, 0, checked.stderr || checked.stdout); let singBox; let curl; let singBoxLog = ''; const controller = new AbortController(); const requestArrived = deferred(); const releaseResponse = deferred(); const firstBatch = deferred(); const firstStatus = deferred(); const newEvent = deferred(); const updateEvent = deferred(); const closedEvent = deferred(); let connectionId = ''; const target = http.createServer(async (_request, response) => { requestArrived.resolve(); await releaseResponse.promise; response.writeHead(200, { 'content-type': 'application/octet-stream' }); for (let index = 0; index < 8; index += 1) { response.write(Buffer.alloc(32 * 1024, 97 + index)); await new Promise((resolve) => setTimeout(resolve, 150)); } response.end('done'); }); try { target.listen(18080, '127.0.0.1'); await once(target, 'listening'); singBox = spawn('sing-box', ['run', '-c', CONFIG_PATH], { stdio: ['ignore', 'pipe', 'pipe'] }); for (const stream of [singBox.stdout, singBox.stderr]) { stream.on('data', (chunk) => { singBoxLog = `${singBoxLog}${chunk}`.slice(-8_000); }); } const client = createClient(StartedService, createGrpcTransport({ baseUrl: `http://127.0.0.1:${API_PORT}`, })); let apiVersion; for (let attempt = 0; attempt < 50; attempt += 1) { try { apiVersion = await client.getVersion({}, { signal: controller.signal, timeoutMs: 200 }); break; } catch { await new Promise((resolve) => setTimeout(resolve, 100)); } } assert.ok(apiVersion, `native h2c API did not start\n${singBoxLog}`); assert.equal(apiVersion.version, EXPECTED_VERSION); assert.ok(apiVersion.apiVersion >= 1); const started = await client.getStartedAt({}, { signal: controller.signal, timeoutMs: 1_000 }); assert.ok(started.startedAt > 0n); const reader = (async () => { try { for await (const batch of client.subscribeConnections( { interval: 100_000_000n }, { signal: controller.signal }, )) { firstBatch.resolve(); for (const event of batch.events) { if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW && event.connection?.inbound === 'mixed-in') { connectionId = event.id || event.connection.id; if (connectionId) newEvent.resolve(event); } if (connectionId && event.id === connectionId && event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE && (event.uplinkDelta > 0n || event.downlinkDelta > 0n)) { updateEvent.resolve(event); } if (connectionId && event.id === connectionId && event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED) { closedEvent.resolve(event); } } } } catch (error) { if (!controller.signal.aborted) throw error; } })(); const statusReader = (async () => { try { for await (const status of client.subscribeStatus( { interval: 100_000_000n }, { signal: controller.signal }, )) { firstStatus.resolve(status); } } catch (error) { if (!controller.signal.aborted) throw error; } })(); await timeout(firstBatch.promise, 'initial connection snapshot'); const status = await timeout(firstStatus.promise, 'first status snapshot'); assert.equal(status.trafficAvailable, true); assert.ok(Number.isInteger(status.connectionsIn) && status.connectionsIn >= 0); assert.ok(Number.isInteger(status.connectionsOut) && status.connectionsOut >= 0); assert.ok(status.uplinkTotal >= 0n); assert.ok(status.downlinkTotal >= 0n); curl = spawn('curl', [ '--noproxy', '', '-fsS', '-x', 'http://127.0.0.1:18081', 'http://127.0.0.1:18080/lifecycle', ], { stdio: ['ignore', 'pipe', 'pipe'] }); let curlBody = Buffer.alloc(0); let curlError = ''; curl.stdout.on('data', (chunk) => { curlBody = Buffer.concat([curlBody, chunk]); }); curl.stderr.on('data', (chunk) => { curlError += chunk; }); await timeout(requestArrived.promise, 'proxied request'); const opened = await timeout(newEvent.promise, 'NEW lifecycle event'); assert.equal(opened.connection?.inboundType, 'mixed'); assert.equal(opened.connection?.outbound, 'direct'); releaseResponse.resolve(); const [curlCode] = await timeout(once(curl, 'exit'), 'proxied response'); assert.equal(curlCode, 0, curlError); assert.ok(curlBody.length > 256 * 1024); await timeout(updateEvent.promise, 'UPDATE lifecycle event'); const closed = await timeout(closedEvent.promise, 'CLOSED lifecycle event'); assert.ok(closed.closedAt > 0n || (closed.connection?.closedAt || 0n) > 0n); controller.abort(); await Promise.all([reader, statusReader]); console.log(`PASS native h2c API ${apiVersion.version} (api ${apiVersion.apiVersion})`); console.log(`PASS started/status ${started.startedAt} traffic=${status.trafficAvailable} in=${status.connectionsIn} out=${status.connectionsOut}`); console.log(`PASS lifecycle NEW UPDATE CLOSED ${connectionId}`); console.log('PASS active HTTP connection through generated mixed-in -> direct'); } finally { controller.abort(); if (curl?.exitCode === null) curl.kill('SIGKILL'); await stop(singBox); await new Promise((resolve) => target.close(resolve)); } NODE CONTAINER_SCRIPT