From 920a78fdae0755ed2259a3d47fa3935b85817590 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 27 Aug 2026 12:16:07 +0500 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5=20=D0=B8=D0=B7=20productio?= =?UTF-8?q?n-=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20share=20URI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внутри e2e-hysteria.sh жила вторая реализация hysteria2:// URI на bash. Go-юнит-тесты проверяли production-генератор, e2e проверял свою функцию - и дрейф любой из двух реализаций оставлял обе группы тестов зелёными. Фраза "реальный клиент подключается именно по ссылке, которую выдаёт HY2XS" была неточной. Билдер ссылки вынесен в экспортируемую service.BuildHysteria2ShareURI, production-путь Hysteria2Url стал её тонкой обёрткой. Новая тестовая утилита apps/tools/share-uri печатает ссылку тем же кодом; в production-бинарь админки она не входит. Единственное расхождение с пользовательской ссылкой - insecure=1: e2e работает на самоподписанном сертификате. Расхождение ограничено с трёх сторон: - e2e отдельно печатает и проверяет production-вариант ссылки (insecure=0, корректные obfs и sni); - TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam доказывает, что кроме этого параметра ссылки совпадают; - TestBuildHysteria2Url_ProductionPathNeverDisablesVerification фиксирует, что production-путь никогда не передаёт insecure=1. Для запуска e2e теперь нужен Go (GO_BIN). --- apps/service/hysteria2_api.go | 64 +++++++++++++++++++++----- apps/service/hysteria2_api_test.go | 66 ++++++++++++++++++++++++++ apps/tools/share-uri/main.go | 53 +++++++++++++++++++++ tools/test/e2e-hysteria.sh | 74 ++++++++++++++++++++++++------ 4 files changed, 232 insertions(+), 25 deletions(-) create mode 100644 apps/tools/share-uri/main.go diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go index f89ab1b..0bdd982 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -178,26 +178,68 @@ func isShareableObfsType(obfsType string) bool { return obfsType == "salamander" || obfsType == "gecko" } -func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string { +// ShareURIOptions — вход генератора клиентской ссылки. +// +// Тип экспортируется, чтобы end-to-end проверка подключалась РОВНО тем же +// кодом, который выдаёт ссылки пользователю. Раньше e2e собирал URI +// собственной реализацией на bash, и дрейф любой из двух реализаций +// оставлял обе группы тестов зелёными. +type ShareURIOptions struct { + Secret string + Host string + Port int + Obfs bo.ObfsShareConfig + SNI string + Remark string + + // Insecure отключает проверку сертификата на стороне клиента. + // + // В production всегда false: Hysteria2Url другого значения не передаёт, + // и это закреплено тестом. Поле существует только ради e2e, который + // работает на самоподписанном сертификате и иначе не смог бы + // использовать production-генератор. + Insecure bool +} + +// BuildHysteria2ShareURI собирает ссылку по официальной URI-схеме Hysteria 2. +func BuildHysteria2ShareURI(opts ShareURIOptions) string { query := url.Values{} - if isShareableObfsType(obfs.Type) && obfs.Password != "" { - query.Set("obfs", obfs.Type) - query.Set("obfs-password", obfs.Password) + if isShareableObfsType(opts.Obfs.Type) && opts.Obfs.Password != "" { + query.Set("obfs", opts.Obfs.Type) + query.Set("obfs-password", opts.Obfs.Password) } - if sni != "" { - query.Set("sni", sni) + if opts.SNI != "" { + query.Set("sni", opts.SNI) + } + if opts.Insecure { + query.Set("insecure", "1") + } else { + query.Set("insecure", "0") } - query.Set("insecure", "0") u := url.URL{ Scheme: "hysteria2", - User: url.User(conPass), - Host: net.JoinHostPort(hostname, strconv.Itoa(port)), + User: url.User(opts.Secret), + Host: net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)), Path: "/", RawQuery: query.Encode(), } - if strings.TrimSpace(remark) != "" { - u.Fragment = remark + if strings.TrimSpace(opts.Remark) != "" { + u.Fragment = opts.Remark } return u.String() } + +// buildHysteria2Url — production-путь: проверка сертификата никогда не +// отключается. +func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string { + return BuildHysteria2ShareURI(ShareURIOptions{ + Secret: conPass, + Host: hostname, + Port: port, + Obfs: obfs, + SNI: sni, + Remark: remark, + Insecure: false, + }) +} diff --git a/apps/service/hysteria2_api_test.go b/apps/service/hysteria2_api_test.go index 31e730d..4214a53 100644 --- a/apps/service/hysteria2_api_test.go +++ b/apps/service/hysteria2_api_test.go @@ -165,6 +165,72 @@ func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) { } } +// Инвариант production-пути: проверка сертификата не отключается никогда. +// Флаг Insecure существует только для e2e с самоподписанным сертификатом. +func TestBuildHysteria2Url_ProductionPathNeverDisablesVerification(t *testing.T) { + cases := []bo.ObfsShareConfig{ + {}, + {Type: "gecko", Password: "p"}, + {Type: "salamander", Password: "p"}, + } + + for _, obfs := range cases { + raw := buildHysteria2Url("pass", "vpn.example.com", 443, obfs, "vpn.example.com", "remark") + if got := mustParse(t, raw).Query().Get("insecure"); got != "0" { + t.Fatalf("production share URI must carry insecure=0, got %q (raw=%s)", got, raw) + } + } +} + +// e2e использует production-генератор, поэтому единственное расхождение с +// пользовательской ссылкой обязано быть ровно одним параметром. +func TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam(t *testing.T) { + opts := shareOptionsFixture() + + production := BuildHysteria2ShareURI(opts) + opts.Insecure = true + relaxed := BuildHysteria2ShareURI(opts) + + productionURL := mustParse(t, production) + testURL := mustParse(t, relaxed) + + if productionURL.Host != testURL.Host || productionURL.Path != testURL.Path { + t.Fatalf("host/path must not depend on the insecure flag: %s vs %s", production, relaxed) + } + if productionURL.User.String() != testURL.User.String() { + t.Fatalf("userinfo must not depend on the insecure flag") + } + if productionURL.Fragment != testURL.Fragment { + t.Fatalf("remark must not depend on the insecure flag") + } + + productionQuery := productionURL.Query() + testQuery := testURL.Query() + + if productionQuery.Get("insecure") != "0" || testQuery.Get("insecure") != "1" { + t.Fatalf("unexpected insecure values: %q / %q", + productionQuery.Get("insecure"), testQuery.Get("insecure")) + } + + productionQuery.Del("insecure") + testQuery.Del("insecure") + if productionQuery.Encode() != testQuery.Encode() { + t.Fatalf("only the insecure param may differ:\n %s\n %s", + productionQuery.Encode(), testQuery.Encode()) + } +} + +func shareOptionsFixture() ShareURIOptions { + return ShareURIOptions{ + Secret: "peer-secret", + Host: "vpn.example.com", + Port: 443, + Obfs: bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"}, + SNI: "vpn.example.com", + Remark: "HY2XS", + } +} + func TestResolveShareSni(t *testing.T) { tests := []struct { name string diff --git a/apps/tools/share-uri/main.go b/apps/tools/share-uri/main.go new file mode 100644 index 0000000..ce9047c --- /dev/null +++ b/apps/tools/share-uri/main.go @@ -0,0 +1,53 @@ +// Утилита для end-to-end проверки: печатает клиентскую ссылку тем же +// production-кодом, который выдаёт её пользователю панели. +// +// Существует, чтобы у HY2XS была ровно ОДНА реализация hysteria2:// URI. +// Раньше tools/test/e2e-hysteria.sh собирал ссылку собственной функцией на +// bash, и дрейф любой из двух реализаций оставлял обе группы тестов зелёными. +// +// В production-бинарь админки не входит: это отдельный пакет, который не +// импортируется приложением. +// +// go run ./tools/share-uri \ +// --secret peer-secret --host 127.0.0.1 --port 34643 \ +// --obfs gecko --obfs-password p --sni hy2xs-e2e.local [--insecure] +package main + +import ( + "flag" + "fmt" + "os" + + "hy2xs-admin/model/bo" + "hy2xs-admin/service" +) + +func main() { + secret := flag.String("secret", "", "peer connection secret (userinfo)") + host := flag.String("host", "", "public host") + port := flag.Int("port", 0, "public port") + obfsType := flag.String("obfs", "", "obfuscation type: gecko or salamander") + obfsPassword := flag.String("obfs-password", "", "obfuscation password") + sni := flag.String("sni", "", "TLS SNI") + remark := flag.String("remark", "", "link remark (fragment)") + insecure := flag.Bool("insecure", false, "skip client-side certificate verification (test only)") + flag.Parse() + + if *secret == "" || *host == "" || *port <= 0 || *port > 65535 { + fmt.Fprintln(os.Stderr, "share-uri: --secret, --host and a valid --port are required") + os.Exit(2) + } + + fmt.Println(service.BuildHysteria2ShareURI(service.ShareURIOptions{ + Secret: *secret, + Host: *host, + Port: *port, + Obfs: bo.ObfsShareConfig{ + Type: *obfsType, + Password: *obfsPassword, + }, + SNI: *sni, + Remark: *remark, + Insecure: *insecure, + })) +} diff --git a/tools/test/e2e-hysteria.sh b/tools/test/e2e-hysteria.sh index 5ee58d0..ed6daad 100755 --- a/tools/test/e2e-hysteria.sh +++ b/tools/test/e2e-hysteria.sh @@ -8,15 +8,17 @@ set -euo pipefail # 2. TLS handshake; # 3. handshake с обфускацией (gecko и salamander); # 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому; -# 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI; +# 5. клиент подключается ИМЕННО по ссылке, которую выдаёт production-код +# генератора share URI (apps/service, через apps/tools/share-uri); # 6. TCP forwarding через туннель; # 7. UDP forwarding через туннель; # 8. trafficStats API: валидный и невалидный secret; # 9. per-peer accounting; # 10. перезапуск сервера и быстрое переподключение клиента. # -# Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует -# установленной админки и базы. Рассчитан на Debian 13 amd64. +# Скрипт поднимает mock HY2XS auth endpoint, поэтому не требует установленной +# админки и базы, но требует Go: ссылка берётся из production-генератора, а не +# из второй реализации внутри теста. Рассчитан на Debian 13 amd64. # # ./tools/test/e2e-hysteria.sh # HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander @@ -27,6 +29,7 @@ cd "$ROOT_DIR" OBFS_TYPES=() HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}" BUN_BIN="${BUN_BIN:-bun}" +GO_BIN="${GO_BIN:-go}" SERVER_PORT="${E2E_SERVER_PORT:-34643}" STATS_PORT="${E2E_STATS_PORT:-34644}" @@ -60,6 +63,7 @@ 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) + GO_BIN path to go (default: go from PATH); used to run the production share-URI generator EOF exit 2 } @@ -95,6 +99,7 @@ preflight() { require_tool curl require_tool ss command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)" + command -v "$GO_BIN" >/dev/null 2>&1 || die "go not found (set GO_BIN)" local port for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \ @@ -265,17 +270,55 @@ start_server() { 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() { +# Ссылка берётся из production-генератора HY2XS, а не собирается тестом. +# +# Раньше здесь жила вторая реализация URI на bash: расхождение между ней и +# кодом админки оставляло зелёными обе группы тестов. Теперь реальный клиент +# подключается ровно по той ссылке, которую выдаёт пользователю панель. +# +# Единственное расхождение с пользовательской ссылкой — insecure=1: e2e +# работает на самоподписанном сертификате. Это отдельно закреплено +# Go-тестом TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam. +share_uri_from_production_code() { local obfs_type="$1" local secret="$2" + local insecure_flag="$3" - 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" + local args=( + --secret "$secret" + --host 127.0.0.1 + --port "$SERVER_PORT" + --obfs "$obfs_type" + --obfs-password "$OBFS_PASSWORD" + --sni "$SNI_NAME" + ) + if [ "$insecure_flag" = "insecure" ]; then + args+=(--insecure) + fi + + (cd apps && GOTOOLCHAIN=local "$GO_BIN" run ./tools/share-uri "${args[@]}") \ + || die "production share URI generator failed" +} + +# Проверяем именно тот вид ссылки, который получает пользователь. +assert_production_share_uri_shape() { + local obfs_type="$1" + local production_uri + production_uri="$(share_uri_from_production_code "$obfs_type" "$VALID_SECRET" "verify")" + + case "$production_uri" in + hysteria2://*) ;; + *) die "production share URI has an unexpected scheme: $production_uri" ;; + esac + + printf '%s' "$production_uri" | grep -q "obfs=$obfs_type" \ + || die "production share URI does not carry obfs=$obfs_type" + printf '%s' "$production_uri" | grep -q 'insecure=0' \ + || die "production share URI must carry insecure=0" + printf '%s' "$production_uri" | grep -q "sni=$SNI_NAME" \ + || die "production share URI does not carry the SNI" + + pass "production share URI keeps certificate verification enabled" } # Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это @@ -435,7 +478,8 @@ 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")" + write_client_config_from_uri \ + "$(share_uri_from_production_code "$obfs_type" "$INVALID_SECRET" "insecure")" HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \ >"$WORK_DIR/client-denied.log" 2>&1 & @@ -481,13 +525,15 @@ run_profile() { start_server pass "server accepted the HY2XS config and started (TLS + $obfs_type)" + assert_production_share_uri_shape "$obfs_type" + local uri - uri="$(build_share_uri "$obfs_type" "$VALID_SECRET")" + uri="$(share_uri_from_production_code "$obfs_type" "$VALID_SECRET" "insecure")" 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)" + pass "client connected using the production-generated hysteria2:// URI (TLS + $obfs_type handshake)" assert_tcp_forwarding assert_udp_forwarding