Files
HY2XS_flamy/tools/test/e2e-hysteria.sh
T
founder ddf0ddf71e feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1.

Build:
- версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и
  замораживается в metadata пакета (version + immutable url + sha256);
- compatibility gate: реальный бинарник должен принять канонический конфиг
  HY2XS для gecko и salamander до создания пакета;
- сборка прогоняет тесты оркестратора и админки.

Конфигурационный контракт:
- HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast;
- obfs стал настоящим union gecko|salamander, gecko — default;
- obfs-блок рендерится оркестратором целиком, два подтипа одновременно
  структурно невозможны;
- современный baseline: congestion bbr/standard, disableLossCompensation=false,
  disableStatelessReset=false, полный quic-блок.

Исправления:
- share URI для gecko: генератор был завязан на Obfs.Salamander.Password и
  выдавал нерабочую ссылку при любой другой обфускации;
- SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file;
- экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль;
- экспорт терял неизвестные upstream-поля при round-trip через типизированную
  модель;
- renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом;
- namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0).

Тесты:
- 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover;
- тесты URI и экспорта в Go;
- tools/test/e2e-hysteria.sh с реальным клиентом Hysteria.

UX:
- подсказки и примеры в форме создания пира.

Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов),
документация на русском.
2026-08-27 08:15:02 +05:00

528 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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" <<EOF
const validSecret = ${VALID_SECRET@Q};
const machineToken = ${STATS_SECRET@Q};
Bun.serve({
hostname: "127.0.0.1",
port: ${AUTH_PORT},
async fetch(request) {
const url = new URL(request.url);
if (url.searchParams.get("access_token") !== machineToken) {
return new Response("forbidden", { status: 403 });
}
const body = await request.json().catch(() => 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" <<EOF
Bun.listen({
hostname: "127.0.0.1",
port: ${ORIGIN_TCP_PORT},
socket: {
open(socket) {
socket.write("HY2XS-TCP-OK\n");
socket.flush();
socket.end();
},
data() {},
error() {}
}
});
await Bun.udpSocket({
hostname: "127.0.0.1",
port: ${ORIGIN_UDP_PORT},
socket: {
data(socket, _buf, port, address) {
socket.send("HY2XS-UDP-OK", port, address);
}
}
});
EOF
"$BUN_BIN" run "$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" <<EOF
server: $1
tcpForwarding:
- listen: 127.0.0.1:${FORWARD_TCP_PORT}
remote: 127.0.0.1:${ORIGIN_TCP_PORT}
udpForwarding:
- listen: 127.0.0.1:${FORWARD_UDP_PORT}
remote: 127.0.0.1:${ORIGIN_UDP_PORT}
timeout: 20s
EOF
}
start_client() {
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$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<string>(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/<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 "$@"