Add native traffic inspection to Harbor Connect and Gateway

This commit is contained in:
2026-08-31 05:19:15 +03:00
parent 116686a138
commit 4d066cb879
62 changed files with 10975 additions and 220 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
readonly RC_VERSION='1.14.0-rc.5'
readonly IMAGE="harbor-singbox-client-rc-test:${RC_VERSION}"
FIXTURES="$(mktemp -d)"
SUFFIX="${FIXTURES##*/}"
readonly SUFFIX="${SUFFIX//[^[:alnum:]]/}"
readonly NETWORK="harbor-singbox-rc-${SUFFIX}"
readonly TARGET="harbor-singbox-rc-target-${SUFFIX}"
readonly PROXY="harbor-singbox-rc-proxy-${SUFFIX}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NETWORK_CREATED=false
TARGET_CREATED=false
PROXY_CREATED=false
cleanup() {
[[ "$PROXY_CREATED" == true ]] && docker rm -f "$PROXY" >/dev/null 2>&1 || true
[[ "$TARGET_CREATED" == true ]] && docker rm -f "$TARGET" >/dev/null 2>&1 || true
[[ "$NETWORK_CREATED" == true ]] && docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker image rm "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$FIXTURES"
}
trap cleanup EXIT
check_config() {
local name="$1"
local output
local unexpected
if ! output="$(docker run --rm \
-v "$FIXTURES:/fixtures:ro" \
--entrypoint sing-box \
"$IMAGE" check -c "/fixtures/${name}.json" 2>&1)"; then
printf '%s\n' "$output" >&2
return 1
fi
unexpected="$(printf '%s\n' "$output" \
| grep -Ei 'warn|deprecated' \
| grep -Evi 'independent_cache.*DNS option is deprecated' || true)"
if [[ -n "$unexpected" ]]; then
printf 'unexpected warning for %s:\n%s\n' "$name" "$unexpected" >&2
return 1
fi
printf 'PASS config %s\n' "$name"
}
docker build \
--build-arg "SINGBOX_VERSION=${RC_VERSION}" \
-t "$IMAGE" \
-f "$ROOT/Dockerfile.client" \
"$ROOT"
version_output="$(docker run --rm --entrypoint sing-box "$IMAGE" version)"
printf '%s\n' "$version_output"
grep -Fxq "sing-box version ${RC_VERSION}" <<< "$version_output"
cat > "$FIXTURES/generate-configs.mjs" <<'EOF'
import fs from 'node:fs';
import { parseSubscriptionBody } from '/app/dist/server/subscription.js';
import { buildGatewayConfig } from '/app/dist/server/singbox.js';
const reality = 'vless://00000000-0000-4000-8000-000000000001@reality.example.test:443?security=reality&type=tcp&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123456789abcdef&sni=cover.example.test&fp=chrome#Reality';
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 generated(link, clientDirect = false) {
const parsed = parseSubscriptionBody(Buffer.from(link).toString('base64'));
return buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect });
}
for (const [name, config] of [
['local-vpn', generated(websocket)],
['gateway-direct', generated(websocket, true)],
['vless-reality', generated(reality)],
['vless-tls-websocket', generated(websocket)],
]) {
fs.writeFileSync(`/fixtures/${name}.json`, JSON.stringify(config));
}
EOF
docker run --rm \
-e APP_MODE=client \
-e PROXY_PORT=18081 \
-e DIAGNOSTICS_PROXY_PORT=18082 \
-e DATA_DIR=/tmp/harbor-rc \
-e SING_BOX_CACHE=/tmp/harbor-rc-cache.db \
-v "$FIXTURES:/fixtures" \
--entrypoint node \
"$IMAGE" /fixtures/generate-configs.mjs
check_config local-vpn
check_config gateway-direct
check_config vless-reality
check_config vless-tls-websocket
docker network create "$NETWORK" >/dev/null
NETWORK_CREATED=true
docker create --name "$TARGET" --network "$NETWORK" --entrypoint node "$IMAGE" \
-e 'require("node:http").createServer((request,response)=>response.end(request.url)).listen(18080,"0.0.0.0")' >/dev/null
TARGET_CREATED=true
docker start "$TARGET" >/dev/null
docker create --name "$PROXY" --network "$NETWORK" \
-v "$FIXTURES:/fixtures:ro" --entrypoint sing-box "$IMAGE" run -c /fixtures/gateway-direct.json >/dev/null
PROXY_CREATED=true
docker start "$PROXY" >/dev/null
for _ in {1..30}; do
if docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS -x "http://${PROXY}:18081" "http://${TARGET}:18080/http" | grep -Fxq '/http'; then
break
fi
sleep 0.2
done
http_body="$(docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS -x "http://${PROXY}:18081" "http://${TARGET}:18080/http")"
socks_body="$(docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS --socks5-hostname "${PROXY}:18081" "http://${TARGET}:18080/socks")"
[[ "$http_body" == '/http' ]]
[[ "$socks_body" == '/socks' ]]
printf 'PASS mixed inbound HTTP\nPASS mixed inbound SOCKS5\n'
+132
View File
@@ -0,0 +1,132 @@
#!/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
+298
View File
@@ -0,0 +1,298 @@
#!/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