#!/usr/bin/env bash set -euo pipefail # HY2XS end-to-end проверка связки «сервер + реальный клиент Hysteria». # # Что проверяется: # 1. сервер принимает сгенерированный HY2XS конфиг и стартует; # 2. TLS handshake; # 3. handshake с обфускацией (gecko и salamander); # 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому; # 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI; # 6. TCP forwarding через туннель; # 7. UDP forwarding через туннель; # 8. trafficStats API: валидный и невалидный secret; # 9. per-peer accounting; # 10. перезапуск сервера и быстрое переподключение клиента. # # Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует # установленной админки и базы. Рассчитан на Debian 13 amd64. # # ./tools/test/e2e-hysteria.sh # HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" OBFS_TYPES=() HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}" BUN_BIN="${BUN_BIN:-bun}" SERVER_PORT="${E2E_SERVER_PORT:-34643}" STATS_PORT="${E2E_STATS_PORT:-34644}" AUTH_PORT="${E2E_AUTH_PORT:-34645}" ORIGIN_TCP_PORT="${E2E_ORIGIN_TCP_PORT:-34646}" ORIGIN_UDP_PORT="${E2E_ORIGIN_UDP_PORT:-34647}" FORWARD_TCP_PORT="${E2E_FORWARD_TCP_PORT:-34648}" FORWARD_UDP_PORT="${E2E_FORWARD_UDP_PORT:-34649}" VALID_SECRET="hy2xs-e2e-valid-peer-secret" INVALID_SECRET="hy2xs-e2e-invalid-peer-secret" STATS_SECRET="hy2xs-e2e-traffic-secret" OBFS_PASSWORD="hy2xs-e2e-obfs-password" SNI_NAME="hy2xs-e2e.local" WORK_DIR="" SERVER_PID="" CLIENT_PID="" AUTH_PID="" ORIGIN_PID="" log() { printf '[hy2xs-e2e] %s\n' "$*"; } step() { printf '\n[hy2xs-e2e] ==> %s\n' "$*"; } pass() { printf '[hy2xs-e2e] ok: %s\n' "$*"; } die() { printf '[hy2xs-e2e] FAIL: %s\n' "$*" >&2; exit 1; } usage() { cat >&2 <<'EOF' Usage: tools/test/e2e-hysteria.sh [--obfs gecko|salamander|all] Environment: HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria) BUN_BIN path to bun (default: bun from PATH) EOF exit 2 } parse_args() { local requested="all" while [ $# -gt 0 ]; do case "$1" in --obfs) requested="${2:-}" shift 2 || true ;; -h|--help) usage ;; *) die "unknown argument: $1" ;; esac done case "$requested" in all) OBFS_TYPES=(gecko salamander) ;; gecko) OBFS_TYPES=(gecko) ;; salamander) OBFS_TYPES=(salamander) ;; *) die "unsupported --obfs value: $requested" ;; esac } require_tool() { command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1" } preflight() { [ -x "$HYSTERIA_BIN" ] || die "Hysteria binary not found or not executable: $HYSTERIA_BIN (set HYSTERIA_BIN)" require_tool openssl require_tool curl require_tool ss command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)" local port for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \ "$ORIGIN_TCP_PORT" "$ORIGIN_UDP_PORT" "$FORWARD_TCP_PORT" "$FORWARD_UDP_PORT"; do if ss -H -lantu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}\$"; then die "port $port is already in use" fi done log "Hysteria under test: $("$HYSTERIA_BIN" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" } kill_pid() { local name="$1" local pid="${!name}" [ -n "$pid" ] || return 0 kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true printf -v "$name" '%s' "" } cleanup() { kill_pid CLIENT_PID kill_pid SERVER_PID kill_pid ORIGIN_PID kill_pid AUTH_PID [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" || true } trap cleanup EXIT wait_for_port() { local port="$1" proto_flag="$2" label="$3" attempts="${4:-30}" local i=0 while [ "$i" -lt "$attempts" ]; do if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}\$"; then return 0 fi sleep 1 i=$((i + 1)) done die "$label did not start listening on port $port" } # Mock HY2XS auth endpoint повторяет контракт /hui/hysteria2/auth: # проверку machine access_token и отказ неразрешённому секрету. start_auth_endpoint() { cat >"$WORK_DIR/auth-server.ts" < null); if (!body || typeof body.auth !== "string" || typeof body.tx !== "number") { return Response.json({ ok: false }, { status: 400 }); } if (body.auth !== validSecret) { return Response.json({ ok: false }); } return Response.json({ ok: true, id: "e2e-peer" }); } }); EOF "$BUN_BIN" run "$WORK_DIR/auth-server.ts" >"$WORK_DIR/auth.log" 2>&1 & AUTH_PID=$! wait_for_port "$AUTH_PORT" "-ltn" "mock auth endpoint" pass "mock HY2XS auth endpoint is up" } # TCP- и UDP-origin: отвечают фиксированной строкой, чтобы можно было # однозначно отличить рабочий туннель от неответа. start_origins() { cat >"$WORK_DIR/origins.ts" <"$WORK_DIR/origins.log" 2>&1 & ORIGIN_PID=$! wait_for_port "$ORIGIN_TCP_PORT" "-ltn" "tcp origin" wait_for_port "$ORIGIN_UDP_PORT" "-lun" "udp origin" pass "TCP and UDP origins are up" } render_server_config() { local obfs_type="$1" ( cd orchestrator "$BUN_BIN" run tools/render-canonical-config.ts \ --package-dir ../package \ --obfs "$obfs_type" \ --tls-mode file \ --cert "$WORK_DIR/server.crt" \ --key "$WORK_DIR/server.key" \ --port "$SERVER_PORT" \ --traffic-stats-port "$STATS_PORT" \ --out "$WORK_DIR/server.yaml" ) || die "could not render canonical server config for obfs=$obfs_type" # Подставляем e2e-секреты и локальный auth endpoint, не трогая структуру # конфига: проверяется тот же YAML, который получает production. "$BUN_BIN" -e ' const [path, obfs, stats, authPort] = process.argv.slice(1); let text = require("node:fs").readFileSync(path, "utf8"); text = text.split("hy2xs-compat-gate-obfs-password").join(obfs); text = text.split("hy2xs-compat-gate-traffic-secret").join(stats); text = text.replace(/url: http:\/\/127\.0\.0\.1:\d+\//, `url: http://127.0.0.1:${authPort}/`); require("node:fs").writeFileSync(path, text); ' "$WORK_DIR/server.yaml" "$OBFS_PASSWORD" "$STATS_SECRET" "$AUTH_PORT" \ || die "could not inject e2e secrets into the server config" grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \ || die "rendered server config does not use obfs type $obfs_type" grep -q "127.0.0.1:${AUTH_PORT}/hui/hysteria2/auth?access_token=" "$WORK_DIR/server.yaml" \ || die "server config lost the HY2XS machine auth token" } start_server() { HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" server -c "$WORK_DIR/server.yaml" \ >>"$WORK_DIR/server.log" 2>&1 & SERVER_PID=$! local i=0 while [ "$i" -lt 20 ]; do if ! kill -0 "$SERVER_PID" 2>/dev/null; then log "--- server log ---" cat "$WORK_DIR/server.log" >&2 || true die "server exited while starting" fi if ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${SERVER_PORT}\$"; then return 0 fi sleep 1 i=$((i + 1)) done log "--- server log ---" cat "$WORK_DIR/server.log" >&2 || true die "server did not start listening on UDP $SERVER_PORT" } urlencode() { "$BUN_BIN" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1" } # Ссылка строится ровно по официальной URI-схеме, как её генерирует админка. build_share_uri() { local obfs_type="$1" local secret="$2" printf 'hysteria2://%s@127.0.0.1:%s/?obfs=%s&obfs-password=%s&sni=%s&insecure=1' \ "$(urlencode "$secret")" "$SERVER_PORT" "$obfs_type" "$(urlencode "$OBFS_PASSWORD")" "$SNI_NAME" } # Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это # ловит расхождение между генератором ссылок и реальным сервером. write_client_config_from_uri() { cat >"$WORK_DIR/client.yaml" <>"$WORK_DIR/client.log" 2>&1 & CLIENT_PID=$! local i=0 while [ "$i" -lt 25 ]; do if ! kill -0 "$CLIENT_PID" 2>/dev/null; then log "--- client log ---" cat "$WORK_DIR/client.log" >&2 || true die "client exited while connecting via the generated share URI" fi if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${FORWARD_TCP_PORT}\$"; then return 0 fi sleep 1 i=$((i + 1)) done log "--- client log ---" cat "$WORK_DIR/client.log" >&2 || true die "client did not open its forwarding listeners" } probe_tcp() { "$BUN_BIN" -e ' const port = Number(process.argv[1]); const chunks: string[] = []; const socket = await Bun.connect({ hostname: "127.0.0.1", port, socket: { data(_s, chunk) { chunks.push(new TextDecoder().decode(chunk)); }, error() {} } }); await Bun.sleep(Number(process.argv[2])); socket.end(); process.stdout.write(chunks.join("")); ' "$FORWARD_TCP_PORT" "3000" 2>/dev/null || true } probe_udp() { "$BUN_BIN" -e ' const port = Number(process.argv[1]); const answer = await new Promise(async (resolve) => { const timer = setTimeout(() => resolve(""), Number(process.argv[2])); const socket = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0, socket: { data(_s, buf) { clearTimeout(timer); resolve(new TextDecoder().decode(buf)); } } }); socket.send("ping", port, "127.0.0.1"); }); process.stdout.write(answer); ' "$FORWARD_UDP_PORT" "5000" 2>/dev/null || true } assert_tcp_forwarding() { local i=0 while [ "$i" -lt 10 ]; do case "$(probe_tcp)" in *HY2XS-TCP-OK*) pass "TCP forwarding works through the tunnel" return 0 ;; esac sleep 1 i=$((i + 1)) done die "TCP forwarding through the tunnel failed" } assert_udp_forwarding() { local i=0 while [ "$i" -lt 10 ]; do case "$(probe_udp)" in *HY2XS-UDP-OK*) pass "UDP forwarding works through the tunnel" return 0 ;; esac sleep 1 i=$((i + 1)) done die "UDP forwarding through the tunnel failed" } assert_traffic_stats() { local code code="$(curl --silent --max-time 5 -o "$WORK_DIR/online.json" -w '%{http_code}' \ -H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/online" || true)" [ "$code" = "200" ] || die "trafficStats /online returned $code for a valid secret" local denied denied="$(curl --silent --max-time 5 -o /dev/null -w '%{http_code}' \ -H "Authorization: definitely-not-the-secret" "http://127.0.0.1:${STATS_PORT}/online" || true)" case "$denied" in 401|403) ;; *) die "trafficStats /online returned $denied for an invalid secret, expected 401/403" ;; esac local traffic_code traffic_code="$(curl --silent --max-time 5 -o "$WORK_DIR/traffic.json" -w '%{http_code}' \ -H "Authorization: ${STATS_SECRET}" "http://127.0.0.1:${STATS_PORT}/traffic" || true)" [ "$traffic_code" = "200" ] || die "trafficStats /traffic returned $traffic_code" grep -q 'e2e-peer' "$WORK_DIR/traffic.json" \ || die "per-peer accounting does not contain the authenticated peer: $(cat "$WORK_DIR/traffic.json")" pass "trafficStats API and per-peer accounting work" } assert_reconnect_after_restart() { kill_pid SERVER_PID start_server local i=0 while [ "$i" -lt 25 ]; do case "$(probe_tcp)" in *HY2XS-TCP-OK*) pass "client recovered the tunnel after a server restart (${i}s)" return 0 ;; esac sleep 1 i=$((i + 1)) done die "client did not recover the tunnel after a server restart" } assert_auth_rejects_unknown_peer() { local obfs_type="$1" kill_pid CLIENT_PID write_client_config_from_uri "$(build_share_uri "$obfs_type" "$INVALID_SECRET")" HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \ >"$WORK_DIR/client-denied.log" 2>&1 & CLIENT_PID=$! # Клиент либо завершается, либо не может пробросить трафик. local i=0 while [ "$i" -lt 15 ]; do if ! kill -0 "$CLIENT_PID" 2>/dev/null; then kill_pid CLIENT_PID pass "unauthorized peer is rejected by HY2XS HTTP auth (client exited)" return 0 fi if grep -Eqi 'auth|denied|reject|unauthor' "$WORK_DIR/client-denied.log"; then kill_pid CLIENT_PID pass "unauthorized peer is rejected by HY2XS HTTP auth (auth failure reported)" return 0 fi sleep 1 i=$((i + 1)) done local leaked leaked="$(probe_tcp)" kill_pid CLIENT_PID case "$leaked" in *HY2XS-TCP-OK*) die "server accepted a peer that HY2XS auth must reject" ;; esac pass "unauthorized peer cannot pass traffic" } run_profile() { local obfs_type="$1" step "profile: obfs=$obfs_type" : >"$WORK_DIR/server.log" : >"$WORK_DIR/client.log" render_server_config "$obfs_type" start_server pass "server accepted the HY2XS config and started (TLS + $obfs_type)" local uri uri="$(build_share_uri "$obfs_type" "$VALID_SECRET")" log "share URI under test: ${uri//$OBFS_PASSWORD/}" write_client_config_from_uri "$uri" start_client pass "client connected using the generated hysteria2:// URI (TLS + $obfs_type handshake)" assert_tcp_forwarding assert_udp_forwarding assert_traffic_stats assert_reconnect_after_restart assert_auth_rejects_unknown_peer "$obfs_type" kill_pid CLIENT_PID kill_pid SERVER_PID pass "profile obfs=$obfs_type passed" } main() { parse_args "$@" preflight WORK_DIR="$(mktemp -d)" log "work dir: $WORK_DIR" openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ -subj "/CN=${SNI_NAME}" \ -addext "subjectAltName=DNS:${SNI_NAME}" \ -keyout "$WORK_DIR/server.key" -out "$WORK_DIR/server.crt" >/dev/null 2>&1 \ || die "could not generate the e2e certificate" start_auth_endpoint start_origins local obfs_type for obfs_type in "${OBFS_TYPES[@]}"; do run_profile "$obfs_type" done step "all HY2XS end-to-end checks passed" } main "$@"