package service import ( "net/url" "strings" "testing" "hy2xs-admin/model/bo" "gopkg.in/yaml.v3" ) func mustParse(t *testing.T, raw string) *url.URL { t.Helper() parsed, err := url.Parse(raw) if err != nil { t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw) } if parsed.Scheme != "hysteria2" { t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme) } return parsed } func TestBuildHysteria2Url_GeckoObfs(t *testing.T) { raw := buildHysteria2Url( "con-pass", "vpn.example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"}, "vpn.example.com", "", ) q := mustParse(t, raw).Query() if q.Get("obfs") != "gecko" { t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs")) } if q.Get("obfs-password") != "gecko-secret" { t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password")) } if q.Get("sni") != "vpn.example.com" { t.Fatalf("expected sni, got %q", q.Get("sni")) } if q.Get("insecure") != "0" { t.Fatalf("expected insecure=0, got %q", q.Get("insecure")) } } func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) { raw := buildHysteria2Url( "con-pass", "vpn.example.com", 443, bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"}, "vpn.example.com", "", ) q := mustParse(t, raw).Query() if q.Get("obfs") != "salamander" { t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs")) } if q.Get("obfs-password") != "salamander-secret" { t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password")) } } func TestBuildHysteria2Url_NoObfs(t *testing.T) { raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "") parsed := mustParse(t, raw) if parsed.Host != "example.com:8443" { t.Fatalf("unexpected host: %s", parsed.Host) } q := parsed.Query() if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" { t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery) } if q.Get("insecure") != "0" { t.Fatalf("expected insecure=0, got %q", q.Get("insecure")) } } func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) { // Неизвестный тип не должен попадать в ссылку: клиент получил бы // параметр, который не понимает. raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "") q := mustParse(t, raw).Query() if q.Get("obfs") != "" { t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs")) } } func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) { raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "") q := mustParse(t, raw).Query() if q.Get("obfs") != "" || q.Get("obfs-password") != "" { t.Fatalf("obfs without password must not be shared: %s", q.Encode()) } } func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) { raw := buildHysteria2Url( "u@ser:#&=+ pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"}, "exa mple.com", "my remark #1", ) parsed := mustParse(t, raw) if parsed.User == nil { t.Fatal("expected userinfo to be present") } if parsed.User.Username() != "u@ser:#&=+ pass" { t.Fatalf("expected decoded userinfo to match source, got %q", parsed.User.Username()) } q := parsed.Query() if q.Get("obfs-password") != "obf+s&pass=@x" { t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password")) } if q.Get("sni") != "exa mple.com" { t.Fatalf("expected decoded sni, got %q", q.Get("sni")) } if parsed.Fragment != "my remark #1" { t.Fatalf("expected decoded fragment, got %q", parsed.Fragment) } if strings.Contains(raw, "u@ser:#&=+ pass") { t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw) } } // Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link // превращался в пробел. Проверяем, что кодирование однозначно. func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) { cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"} for _, value := range cases { raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "") parsed := mustParse(t, raw) if got := parsed.User.Username(); got != value { t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw) } if got := parsed.Query().Get("obfs-password"); got != value { t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw) } } // Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть // прочитан клиентом как пробел, а пробел кодируется отдельно от него. plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "") if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") { t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus) } space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "") if mustParse(t, space).Query().Get("obfs-password") == "a+b" { t.Fatalf("space and '+' must not collapse to the same value: %s", space) } } // Инвариант 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 acmeDomain string publicHost string envDomain string want string }{ {name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"}, {name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"}, {name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"}, {name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""}, {name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""}, {name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Setenv("HY2XS_DOMAIN", tc.envDomain) if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want { t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q", tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want) } }) } } func TestObfsShare_FromServerConfig(t *testing.T) { tests := []struct { name string yamlConfig string wantType string wantPassword string }{ { name: "gecko", yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n", wantType: "gecko", wantPassword: "gecko-pass", }, { name: "salamander", yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n", wantType: "salamander", wantPassword: "salamander-pass", }, { name: "no obfs section", yamlConfig: "listen: 0.0.0.0:443\n", }, { name: "type without matching subsection", yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n", }, { name: "unknown type", yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var config bo.Hysteria2ServerConfig if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil { t.Fatalf("unmarshal failed: %v", err) } got := config.ObfsShare() if got.Type != tc.wantType || got.Password != tc.wantPassword { t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword) } }) } } func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) { raw := `listen: 0.0.0.0:443 tls: cert: /etc/hysteria/server.crt key: /etc/hysteria/server.key clientCA: /etc/hysteria/client-ca.crt ech: keyPath: /etc/hysteria/ech.pem obfs: type: gecko gecko: password: p minPacketSize: 512 maxPacketSize: 1200 bandwidth: up: 50 mbps down: 50 mbps disableLossCompensation: false congestion: type: bbr bbrProfile: standard quic: disableStatelessReset: false mimic: enabled: false interface: eth0 xdpMode: skb realm: stunServers: - stun.example.com:3478 ipMode: dual portMapping: enabled: false timeout: 10s masquerade: type: proxy proxy: url: https://example.com rewriteHost: true insecure: false xForwarded: true trafficStats: listen: 127.0.0.1:36712 ` var config bo.Hysteria2ServerConfig if err := yaml.Unmarshal([]byte(raw), &config); err != nil { t.Fatalf("unmarshal failed: %v", err) } if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" { t.Fatal("ech.keyPath was not parsed") } if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" { t.Fatal("tls.clientCA was not parsed") } if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" { t.Fatal("congestion.type was not parsed") } if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" { t.Fatal("congestion.bbrProfile was not parsed") } if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation { t.Fatal("bandwidth.disableLossCompensation was not parsed") } if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset { t.Fatal("quic.disableStatelessReset was not parsed") } if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled { t.Fatal("mimic section was not parsed") } if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil { t.Fatal("realm section was not parsed") } if config.Masquerade == nil || config.Masquerade.Proxy == nil || config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded { t.Fatal("masquerade.proxy.xForwarded was not parsed") } }