test(e2e): подключаться по ссылке из production-генератора share URI
Внутри 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).
This commit is contained in:
@@ -178,26 +178,68 @@ func isShareableObfsType(obfsType string) bool {
|
|||||||
return obfsType == "salamander" || obfsType == "gecko"
|
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{}
|
query := url.Values{}
|
||||||
if isShareableObfsType(obfs.Type) && obfs.Password != "" {
|
if isShareableObfsType(opts.Obfs.Type) && opts.Obfs.Password != "" {
|
||||||
query.Set("obfs", obfs.Type)
|
query.Set("obfs", opts.Obfs.Type)
|
||||||
query.Set("obfs-password", obfs.Password)
|
query.Set("obfs-password", opts.Obfs.Password)
|
||||||
}
|
}
|
||||||
if sni != "" {
|
if opts.SNI != "" {
|
||||||
query.Set("sni", 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{
|
u := url.URL{
|
||||||
Scheme: "hysteria2",
|
Scheme: "hysteria2",
|
||||||
User: url.User(conPass),
|
User: url.User(opts.Secret),
|
||||||
Host: net.JoinHostPort(hostname, strconv.Itoa(port)),
|
Host: net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
RawQuery: query.Encode(),
|
RawQuery: query.Encode(),
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(remark) != "" {
|
if strings.TrimSpace(opts.Remark) != "" {
|
||||||
u.Fragment = remark
|
u.Fragment = opts.Remark
|
||||||
}
|
}
|
||||||
return u.String()
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestResolveShareSni(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
+60
-14
@@ -8,15 +8,17 @@ set -euo pipefail
|
|||||||
# 2. TLS handshake;
|
# 2. TLS handshake;
|
||||||
# 3. handshake с обфускацией (gecko и salamander);
|
# 3. handshake с обфускацией (gecko и salamander);
|
||||||
# 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому;
|
# 4. HTTP auth HY2XS: допуск разрешённого пира и отказ неразрешённому;
|
||||||
# 5. клиент подключается ИМЕННО по сгенерированному hysteria2:// URI;
|
# 5. клиент подключается ИМЕННО по ссылке, которую выдаёт production-код
|
||||||
|
# генератора share URI (apps/service, через apps/tools/share-uri);
|
||||||
# 6. TCP forwarding через туннель;
|
# 6. TCP forwarding через туннель;
|
||||||
# 7. UDP forwarding через туннель;
|
# 7. UDP forwarding через туннель;
|
||||||
# 8. trafficStats API: валидный и невалидный secret;
|
# 8. trafficStats API: валидный и невалидный secret;
|
||||||
# 9. per-peer accounting;
|
# 9. per-peer accounting;
|
||||||
# 10. перезапуск сервера и быстрое переподключение клиента.
|
# 10. перезапуск сервера и быстрое переподключение клиента.
|
||||||
#
|
#
|
||||||
# Скрипт самодостаточен: поднимает mock HY2XS auth endpoint, поэтому не требует
|
# Скрипт поднимает mock HY2XS auth endpoint, поэтому не требует установленной
|
||||||
# установленной админки и базы. Рассчитан на Debian 13 amd64.
|
# админки и базы, но требует Go: ссылка берётся из production-генератора, а не
|
||||||
|
# из второй реализации внутри теста. Рассчитан на Debian 13 amd64.
|
||||||
#
|
#
|
||||||
# ./tools/test/e2e-hysteria.sh
|
# ./tools/test/e2e-hysteria.sh
|
||||||
# HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander
|
# HYSTERIA_BIN=/tmp/hysteria ./tools/test/e2e-hysteria.sh --obfs salamander
|
||||||
@@ -27,6 +29,7 @@ cd "$ROOT_DIR"
|
|||||||
OBFS_TYPES=()
|
OBFS_TYPES=()
|
||||||
HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}"
|
HYSTERIA_BIN="${HYSTERIA_BIN:-/usr/local/bin/hysteria}"
|
||||||
BUN_BIN="${BUN_BIN:-bun}"
|
BUN_BIN="${BUN_BIN:-bun}"
|
||||||
|
GO_BIN="${GO_BIN:-go}"
|
||||||
|
|
||||||
SERVER_PORT="${E2E_SERVER_PORT:-34643}"
|
SERVER_PORT="${E2E_SERVER_PORT:-34643}"
|
||||||
STATS_PORT="${E2E_STATS_PORT:-34644}"
|
STATS_PORT="${E2E_STATS_PORT:-34644}"
|
||||||
@@ -60,6 +63,7 @@ Usage: tools/test/e2e-hysteria.sh [--obfs gecko|salamander|all]
|
|||||||
Environment:
|
Environment:
|
||||||
HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria)
|
HYSTERIA_BIN path to the Hysteria binary under test (default /usr/local/bin/hysteria)
|
||||||
BUN_BIN path to bun (default: bun from PATH)
|
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
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,7 @@ preflight() {
|
|||||||
require_tool curl
|
require_tool curl
|
||||||
require_tool ss
|
require_tool ss
|
||||||
command -v "$BUN_BIN" >/dev/null 2>&1 || die "bun not found (set BUN_BIN)"
|
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
|
local port
|
||||||
for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_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"
|
die "server did not start listening on UDP $SERVER_PORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
urlencode() {
|
# Ссылка берётся из production-генератора HY2XS, а не собирается тестом.
|
||||||
"$BUN_BIN" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
|
#
|
||||||
}
|
# Раньше здесь жила вторая реализация URI на bash: расхождение между ней и
|
||||||
|
# кодом админки оставляло зелёными обе группы тестов. Теперь реальный клиент
|
||||||
# Ссылка строится ровно по официальной URI-схеме, как её генерирует админка.
|
# подключается ровно по той ссылке, которую выдаёт пользователю панель.
|
||||||
build_share_uri() {
|
#
|
||||||
|
# Единственное расхождение с пользовательской ссылкой — insecure=1: e2e
|
||||||
|
# работает на самоподписанном сертификате. Это отдельно закреплено
|
||||||
|
# Go-тестом TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam.
|
||||||
|
share_uri_from_production_code() {
|
||||||
local obfs_type="$1"
|
local obfs_type="$1"
|
||||||
local secret="$2"
|
local secret="$2"
|
||||||
|
local insecure_flag="$3"
|
||||||
|
|
||||||
printf 'hysteria2://%s@127.0.0.1:%s/?obfs=%s&obfs-password=%s&sni=%s&insecure=1' \
|
local args=(
|
||||||
"$(urlencode "$secret")" "$SERVER_PORT" "$obfs_type" "$(urlencode "$OBFS_PASSWORD")" "$SNI_NAME"
|
--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, а не собирается независимо: именно это
|
# Клиент конфигурируется ИЗ share URI, а не собирается независимо: именно это
|
||||||
@@ -435,7 +478,8 @@ assert_auth_rejects_unknown_peer() {
|
|||||||
local obfs_type="$1"
|
local obfs_type="$1"
|
||||||
|
|
||||||
kill_pid CLIENT_PID
|
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" \
|
HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BIN" client -c "$WORK_DIR/client.yaml" \
|
||||||
>"$WORK_DIR/client-denied.log" 2>&1 &
|
>"$WORK_DIR/client-denied.log" 2>&1 &
|
||||||
@@ -481,13 +525,15 @@ run_profile() {
|
|||||||
start_server
|
start_server
|
||||||
pass "server accepted the HY2XS config and started (TLS + $obfs_type)"
|
pass "server accepted the HY2XS config and started (TLS + $obfs_type)"
|
||||||
|
|
||||||
|
assert_production_share_uri_shape "$obfs_type"
|
||||||
|
|
||||||
local uri
|
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/<obfs-password>}"
|
log "share URI under test: ${uri//$OBFS_PASSWORD/<obfs-password>}"
|
||||||
write_client_config_from_uri "$uri"
|
write_client_config_from_uri "$uri"
|
||||||
|
|
||||||
start_client
|
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_tcp_forwarding
|
||||||
assert_udp_forwarding
|
assert_udp_forwarding
|
||||||
|
|||||||
Reference in New Issue
Block a user