299 lines
11 KiB
Bash
Executable File
299 lines
11 KiB
Bash
Executable File
#!/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
|