fix(hysteria): standard geco share links and persistent uTLS None (#6325)

- Export standard gecko obfs query params in hysteria2 share links
- Enforce packet size bounds across Go and TypeScript link handlers
- Persist uTLS None explicitly and initialize new TLS inbounds to chrome
- Tear down stackTun safely without closeMu deadlock against WriteNotify

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
This commit is contained in:
Rouzbeh†
2026-09-03 18:05:07 +03:30
committed by GitHub
parent f9898e0b24
commit 540caa4e93
14 changed files with 514 additions and 111 deletions
+152
View File
@@ -0,0 +1,152 @@
package sub
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
)
// A salamander mask carrying packetSize (Gecko mode) must export the
// v2rayN-native gecko URI fields, not an fm=<json> dump.
func TestGenHysteriaLinkEmitsGeckoParamsForPacketSize(t *testing.T) {
in := &model.Inbound{
Id: 920001, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
`{"password":"pw","packetSize":"512-1200"}}]}}`,
}
got := (&SubService{}).genHysteriaLink(in, "user")
for _, want := range []string{"obfs=gecko", "obfs-password=pw", "minPacketSize=512", "maxPacketSize=1200"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q\n got: %s", want, got)
}
}
if strings.Contains(got, "obfs=salamander") {
t.Fatalf("gecko mask exported as plain salamander:\n %s", got)
}
if strings.Contains(got, "fm=") {
t.Fatalf("expressed salamander mask must not leak into fm= dump:\n %s", got)
}
}
// Password-only masks keep the plain salamander export.
func TestGenHysteriaLinkSalamanderWithoutPacketSizeUnchanged(t *testing.T) {
in := &model.Inbound{
Id: 920002, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw"}}]}}`,
}
got := (&SubService{}).genHysteriaLink(in, "user")
if !strings.Contains(got, "obfs=salamander") || !strings.Contains(got, "obfs-password=pw") {
t.Fatalf("password-only mask lost its standard export:\n %s", got)
}
for _, bad := range []string{"minPacketSize=", "maxPacketSize="} {
if strings.Contains(got, bad) {
t.Fatalf("unexpected %s in:\n %s", bad, got)
}
}
}
// Import side: obfs=gecko + min/max rebuild a standard salamander+packetSize mask.
func TestParseLinkAcceptsGeckoObfs(t *testing.T) {
parsed, err := link.ParseLink(
"hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=1200#geo")
if err != nil {
t.Fatalf("ParseLink: %v", err)
}
rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
if rawStream == nil {
t.Fatalf("no streamSettings in outbound: %v", parsed.Outbound)
}
streamJSON, err := json.Marshal(rawStream)
if err != nil {
t.Fatalf("marshal stream: %v", err)
}
var stream map[string]any
if err := json.Unmarshal(streamJSON, &stream); err != nil {
t.Fatalf("stream json: %v", err)
}
fm, _ := stream["finalmask"].(map[string]any)
if fm == nil {
t.Fatalf("no finalmask rebuilt: %s", streamJSON)
}
udp, _ := fm["udp"].([]any)
var mask map[string]any
for _, m := range udp {
if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
mask = mm
}
}
if mask == nil {
t.Fatalf("no salamander mask rebuilt: %s", streamJSON)
}
settings, _ := mask["settings"].(map[string]any)
if pw, _ := settings["password"].(string); pw != "pw" {
t.Fatalf("password = %v", settings["password"])
}
if ps, _ := settings["packetSize"].(string); ps != "512-1200" {
t.Fatalf("packetSize = %v, want 512-1200", settings["packetSize"])
}
}
// Half-specified or out-of-bounds gecko ranges must be dropped, not stored.
func TestParseLinkRejectsInvalidGeckoPacketSize(t *testing.T) {
cases := map[string]string{
"half min only": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512#geo",
"half max only": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&maxPacketSize=1200#geo",
"non-numeric": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=abc&maxPacketSize=def#geo",
"zero min": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=0&maxPacketSize=1200#geo",
"inverted": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512#geo",
"over cap": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=4096#geo",
}
for name, uri := range cases {
t.Run(name, func(t *testing.T) {
parsed, err := link.ParseLink(uri)
if err != nil {
t.Fatalf("ParseLink: %v", err)
}
rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
streamJSON, _ := json.Marshal(rawStream)
var stream map[string]any
_ = json.Unmarshal(streamJSON, &stream)
fm, _ := stream["finalmask"].(map[string]any)
if fm == nil {
t.Fatalf("no finalmask rebuilt: %s", streamJSON)
}
udp, _ := fm["udp"].([]any)
for _, m := range udp {
if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
settings, _ := mm["settings"].(map[string]any)
if ps, _ := settings["packetSize"].(string); ps != "" {
t.Fatalf("invalid gecko stored packetSize %q", ps)
}
}
}
})
}
}
// Export side must mirror the TS bounds exactly (1 <= min <= max <= 2048).
func TestParseHysteriaPacketSizeBounds(t *testing.T) {
if got := parseHysteriaPacketSize("0-1200"); got != "" {
t.Fatalf("min below 1 accepted: %q", got)
}
if got := parseHysteriaPacketSize("1200-512"); got != "" {
t.Fatalf("inverted range accepted: %q", got)
}
if got := parseHysteriaPacketSize("512-4096"); got != "" {
t.Fatalf("range over xray cap accepted: %q", got)
}
if got := parseHysteriaPacketSize(" 512 - 1200 "); got != "" {
t.Fatalf("padded range must be rejected: %q", got)
}
if got := parseHysteriaPacketSize("+512-1200"); got != "" {
t.Fatalf("plus-prefixed range must be rejected: %q", got)
}
if got := parseHysteriaPacketSize("512-1200"); got != "512-1200" {
t.Fatalf("valid range = %q", got)
}
}
+6 -1
View File
@@ -66,6 +66,7 @@ type remoteRoutingFetch struct {
}
type remoteRoutingResolver struct {
refreshWG sync.WaitGroup
mu sync.Mutex
loadMu sync.Mutex
loaded bool
@@ -154,7 +155,11 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
r.inflight[key] = fetch
r.mu.Unlock()
common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
r.refreshWG.Add(1)
common.GoRecover("remote-routing-refresh", func() {
defer r.refreshWG.Done()
r.refresh(key, cached, hasCached, fetch)
})
if hasCached {
return cached, true, nil
}
+2
View File
@@ -47,6 +47,8 @@ func remoteRoutingResponse(status int, body string) *http.Response {
func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
t.Helper()
// Wait on refresh goroutines to prevent logging race after test teardown.
resolver.refreshWG.Wait()
deadline := time.Now().Add(2 * time.Second)
for {
resolver.mu.Lock()
+37 -4
View File
@@ -11,13 +11,22 @@ import (
)
func TestExtraSalamanderKeys(t *testing.T) {
if got := extraSalamanderKeys(map[string]any{"password": "pw"}); len(got) != 0 {
if got := extraSalamanderKeys(map[string]any{"password": "pw"}, false); len(got) != 0 {
t.Fatalf("expressible settings reported extras: %v", got)
}
got := extraSalamanderKeys(map[string]any{"password": "pw", "packetSize": "512-1200"})
if want := []string{"packetSize"}; !reflect.DeepEqual(got, want) {
// packetSize exports as the v2rayN gecko fields when expressed; a truly
// unexpressible key always is. An inexpressible packetSize stays extra.
in := map[string]any{"password": "pw", "headerType": "dns"}
if got, want := extraSalamanderKeys(in, false), []string{"headerType"}; !reflect.DeepEqual(got, want) {
t.Fatalf("extraSalamanderKeys = %v, want %v", got, want)
}
full := map[string]any{"password": "pw", "packetSize": "512-1200", "headerType": "dns"}
if got, want := extraSalamanderKeys(full, true), []string{"headerType"}; !reflect.DeepEqual(got, want) {
t.Fatalf("expressed packetSize not excluded: %v, want %v", got, want)
}
if got, want := extraSalamanderKeys(full, false), []string{"headerType", "packetSize"}; !reflect.DeepEqual(got, want) {
t.Fatalf("unexpressed packetSize not reported: %v, want %v", got, want)
}
}
func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
@@ -46,10 +55,34 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T)
}
const unsupportedID = 910002
in := makeInbound(unsupportedID, `{"password":"pw","packetSize":"512-1200"}`)
in := makeInbound(unsupportedID, `{"password":"pw","headerType":"dns"}`)
(&SubService{}).genHysteriaLink(in, "user")
(&SubService{}).genHysteriaLink(in, "user")
if got := countWarnings(unsupportedID); got != 1 {
t.Fatalf("unsupported-settings warning count = %d, want 1", got)
}
}
// A mask with BOTH an expressible packetSize and another key must still warn
// about the leftover key while emitting the gecko URI.
func TestGenHysteriaLinkGeckoStillWarnsOnExtraKeys(t *testing.T) {
in := &model.Inbound{
Id: 910003, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw","packetSize":"512-1200","headerType":"dns"}}]}}`,
}
got := (&SubService{}).genHysteriaLink(in, "user")
if !strings.Contains(got, "obfs=gecko") {
t.Fatalf("gecko not emitted for valid packetSize:\n %s", got)
}
needle := "inbound 910003: salamander settings"
found := 0
for _, line := range logger.GetLogs(100, "warning") {
if strings.Contains(line, needle) {
found++
}
}
if found == 0 {
t.Fatal("leftover salamander key did not warn alongside the gecko export")
}
}
+58 -10
View File
@@ -1176,9 +1176,8 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
}
// salamander obfs (Hysteria2). Emit only the standard URI fields;
// the non-standard fm=<json> finalmask dump breaks mihomo and other
// Hysteria2 clients that reject unknown query params.
// salamander obfs (Hysteria2): standard URI fields only -- an fm=<json>
// dump breaks strict clients. packetSize exports as v2rayN's gecko pair.
if finalmask, ok := stream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
@@ -1188,13 +1187,23 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
settings, _ := mask["settings"].(map[string]any)
if pw, ok := settings["password"].(string); ok && pw != "" {
if extra := extraSalamanderKeys(settings); len(extra) > 0 {
packetSize, _ := settings["packetSize"].(string)
gecko := parseHysteriaPacketSize(packetSize)
if gecko != "" {
params["obfs"] = "gecko"
params["minPacketSize"], params["maxPacketSize"] = splitHysteriaPacketSize(gecko)
}
// packetSize rides its own URI fields; anything else still
// breaks standard clients and must warn even when gecko fires.
if extra := extraSalamanderKeys(settings, gecko != ""); len(extra) > 0 {
warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra)
if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded {
logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra)
}
}
params["obfs"] = "salamander"
if params["obfs"] == "" {
params["obfs"] = "salamander"
}
params["obfs-password"] = pw
break
}
@@ -1260,6 +1269,44 @@ func hysteriaHopPorts(stream map[string]any) string {
return strings.TrimSpace(ports)
}
// gecko packetSize bounds mirror xray-core's salamander buffer cap and the
// frontend editor, so both link generators emit identical URIs.
const (
geckoMinPacketSize = 1
geckoMaxPacketSize = 2048
)
// parseHysteriaPacketSize validates an xray-core salamander packetSize range
// ("512-1200", the Gecko obfs marker). Returns canonical "min-max" or "".
func parseHysteriaPacketSize(value string) string {
minStr, maxStr, ok := strings.Cut(value, "-")
if !ok || minStr == "" || maxStr == "" {
return ""
}
for _, c := range minStr {
if c < '0' || c > '9' {
return ""
}
}
for _, c := range maxStr {
if c < '0' || c > '9' {
return ""
}
}
minVal, err1 := strconv.Atoi(minStr)
maxVal, err2 := strconv.Atoi(maxStr)
if err1 != nil || err2 != nil ||
minVal < geckoMinPacketSize || maxVal < minVal || maxVal > geckoMaxPacketSize {
return ""
}
return fmt.Sprintf("%d-%d", minVal, maxVal)
}
func splitHysteriaPacketSize(value string) (string, string) {
minStr, maxStr, _ := strings.Cut(value, "-")
return minStr, maxStr
}
// loadNodes refreshes nodesByID from the DB. Called once per request so
// the per-inbound resolveInboundAddress lookups are pure map reads.
// We filter to address != ” so a half-configured node row doesn't
@@ -2843,14 +2890,15 @@ func getHostFromXFH(s string) (string, error) {
return s, nil
}
// extraSalamanderKeys lists salamander settings the hysteria2 URI cannot carry.
// A server using them rejects every client built from the emitted link.
func extraSalamanderKeys(settings map[string]any) []string {
// extraSalamanderKeys lists salamander settings unexpressible in hysteria2 URI;
// a server using any reported key rejects clients built from the link.
func extraSalamanderKeys(settings map[string]any, expressedPacketSize bool) []string {
var extra []string
for k := range settings {
if k != "password" {
extra = append(extra, k)
if k == "password" || (k == "packetSize" && expressedPacketSize) {
continue
}
extra = append(extra, k)
}
sort.Strings(extra)
return extra