diff --git a/common/convert/converter.go b/common/convert/converter.go index 4dc20c73..a4faf9b2 100644 --- a/common/convert/converter.go +++ b/common/convert/converter.go @@ -69,8 +69,10 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) { proxies = append(proxies, hysteria) - case "hysteria2", "hy2": - urlHysteria2, err := url.Parse(line) + case "hysteria2", "hy2", "hysteria2+realm", "hy2+realm": + realmMode := strings.HasSuffix(scheme, "+realm") + hopLine, ports := splitHysteria2Ports(line) + urlHysteria2, err := url.Parse(hopLine) if err != nil { continue } @@ -87,6 +89,9 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) { } else { hysteria2["port"] = "443" } + if ports != "" { + hysteria2["ports"] = ports + } hysteria2["obfs"] = query.Get("obfs") hysteria2["obfs-password"] = query.Get("obfs-password") hysteria2["sni"] = query.Get("sni") @@ -94,12 +99,19 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) { if alpn := query.Get("alpn"); alpn != "" { hysteria2["alpn"] = strings.Split(alpn, ",") } - if auth := urlHysteria2.User.String(); auth != "" { - hysteria2["password"] = auth - } hysteria2["fingerprint"] = query.Get("pinSHA256") hysteria2["down"] = query.Get("down") hysteria2["up"] = query.Get("up") + if realmMode { + token, _ := url.PathUnescape(urlHysteria2.User.String()) + realmID, _ := url.PathUnescape(strings.TrimPrefix(urlHysteria2.EscapedPath(), "/")) + hysteria2["realm-opts"] = buildRealmOpts("https://"+urlHysteria2.Host, token, realmID, query["stun"]) + if auth := query.Get("auth"); auth != "" { + hysteria2["password"] = auth + } + } else if auth := urlHysteria2.User.String(); auth != "" { + hysteria2["password"] = auth + } proxies = append(proxies, hysteria2) diff --git a/common/convert/converter_test.go b/common/convert/converter_test.go index c28c0cef..c29ca703 100644 --- a/common/convert/converter_test.go +++ b/common/convert/converter_test.go @@ -40,6 +40,37 @@ func TestConvertsV2Ray_normal(t *testing.T) { assert.NoError(t, err) } +func TestConvertsV2Ray_hysteria2PortHopping(t *testing.T) { + uri := "hysteria2://letmein@example.com:443,5000-6000/?sni=example.com#hop" + proxies, err := ConvertsV2Ray([]byte(uri)) + assert.Nil(t, err) + assert.Equal(t, "example.com", proxies[0]["server"]) + assert.Equal(t, "443", proxies[0]["port"]) + assert.Equal(t, "443,5000-6000", proxies[0]["ports"]) + + _, err = adapter.ParseProxy(proxies[0]) + assert.NoError(t, err) +} + +func TestConvertsV2Ray_hysteria2RealmScheme(t *testing.T) { + uri := "hysteria2+realm://tok3n@rendezvous.example.com:8443/rid42?auth=letmein&stun=stun1:3478&stun=stun2:3478&sni=example.com#realm" + proxies, err := ConvertsV2Ray([]byte(uri)) + assert.Nil(t, err) + p := proxies[0] + assert.Equal(t, "hysteria2", p["type"]) + assert.Equal(t, "letmein", p["password"]) + assert.Equal(t, map[string]any{ + "enable": true, + "server-url": "https://rendezvous.example.com:8443", + "token": "tok3n", + "realm-id": "rid42", + "stun-servers": []string{"stun1:3478", "stun2:3478"}, + }, p["realm-opts"]) + + _, err = adapter.ParseProxy(p) + assert.NoError(t, err) +} + func TestConvertsV2RayMieru(t *testing.T) { mierusTest := "mierus://user:pass@1.2.3.4?handshake-mode=HANDSHAKE_NO_WAIT&mtu=1400&multiplexing=MULTIPLEXING_HIGH&port=6666&port=9998-9999&port=6489&port=4896&profile=default&protocol=TCP&protocol=TCP&protocol=UDP&protocol=UDP&traffic-pattern=CCoQARoECAEQCiIYCAMQASoIMDAwMTAyMDMqCDA0MDUwNjA3" diff --git a/common/convert/v.go b/common/convert/v.go index d94bb0c1..9211216d 100644 --- a/common/convert/v.go +++ b/common/convert/v.go @@ -363,3 +363,52 @@ func parseXHTTPExtra(extra map[string]any, opts map[string]any) { } } } + +func buildRealmOpts(serverURL, token, realmID string, stunServers []string) map[string]any { + opts := map[string]any{"enable": true, "server-url": serverURL} + if token != "" { + opts["token"] = token + } + if realmID != "" { + opts["realm-id"] = realmID + } + if len(stunServers) > 0 { + opts["stun-servers"] = stunServers + } + return opts +} + +func splitHysteria2Ports(line string) (string, string) { + i := strings.Index(line, "://") + if i < 0 { + return line, "" + } + head, rest := line[:i+3], line[i+3:] + auth, tail := rest, "" + if j := strings.IndexAny(rest, "/?#"); j >= 0 { + auth, tail = rest[:j], rest[j:] + } + userinfo := "" + if at := strings.LastIndex(auth, "@"); at >= 0 { + userinfo, auth = auth[:at+1], auth[at+1:] + } + if strings.Contains(auth, "]") { + return line, "" + } + colon := strings.LastIndex(auth, ":") + if colon < 0 { + return line, "" + } + host, ports := auth[:colon], auth[colon+1:] + if !strings.ContainsAny(ports, ",-") { + return line, "" + } + first := ports + if k := strings.IndexAny(ports, ",-"); k >= 0 { + first = ports[:k] + } + if first == "" { + return line, "" + } + return head + userinfo + host + ":" + first + tail, ports +}