920a78fdae
Внутри 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).
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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")
|
|
}
|
|
}
|