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 файлов),
документация на русском.
This commit is contained in:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+527
View File
@@ -0,0 +1,527 @@
#!/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 "$@"