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:
2026-08-27 12:16:07 +05:00
parent 19ffc80130
commit 920a78fdae
4 changed files with 232 additions and 25 deletions
+53 -11
View File
@@ -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,
})
}
+66
View File
@@ -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