diff --git a/internal/sub/salamander_uri_test.go b/internal/sub/salamander_uri_test.go new file mode 100644 index 000000000..2a9a1a06b --- /dev/null +++ b/internal/sub/salamander_uri_test.go @@ -0,0 +1,55 @@ +package sub + +import ( + "reflect" + "strconv" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +func TestExtraSalamanderKeys(t *testing.T) { + if got := extraSalamanderKeys(map[string]any{"password": "pw"}); 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) { + t.Fatalf("extraSalamanderKeys = %v, want %v", got, want) + } +} + +func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) { + makeInbound := func(id int, settings string) *model.Inbound { + return &model.Inbound{ + Id: id, 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":` + settings + `}]}}`, + } + } + countWarnings := func(id int) int { + needle := "inbound " + strconv.Itoa(id) + ": salamander settings" + count := 0 + for _, line := range logger.GetLogs(100, "warning") { + if strings.Contains(line, needle) { + count++ + } + } + return count + } + + const standardID = 910001 + (&SubService{}).genHysteriaLink(makeInbound(standardID, `{"password":"pw"}`), "user") + if got := countWarnings(standardID); got != 0 { + t.Fatalf("password-only warning count = %d, want 0", got) + } + + const unsupportedID = 910002 + in := makeInbound(unsupportedID, `{"password":"pw","packetSize":"512-1200"}`) + (&SubService{}).genHysteriaLink(in, "user") + (&SubService{}).genHysteriaLink(in, "user") + if got := countWarnings(unsupportedID); got != 1 { + t.Fatalf("unsupported-settings warning count = %d, want 1", got) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index b3cf0f405..828287efa 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -9,8 +9,10 @@ import ( "net" "net/url" "slices" + "sort" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -26,6 +28,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" ) +var salamanderWarningSeen sync.Map + // SubService provides business logic for generating subscription links and managing subscription data. type SubService struct { address string @@ -1051,6 +1055,12 @@ 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 { + 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" params["obfs-password"] = pw break @@ -2696,3 +2706,16 @@ 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 { + var extra []string + for k := range settings { + if k != "password" { + extra = append(extra, k) + } + } + sort.Strings(extra) + return extra +}