diff --git a/internal/sub/salamander_uri_test.go b/internal/sub/salamander_uri_test.go index c7add43a6..869110528 100644 --- a/internal/sub/salamander_uri_test.go +++ b/internal/sub/salamander_uri_test.go @@ -1,6 +1,7 @@ package sub import ( + "fmt" "reflect" "strconv" "strings" @@ -86,3 +87,42 @@ func TestGenHysteriaLinkGeckoStillWarnsOnExtraKeys(t *testing.T) { t.Fatal("leftover salamander key did not warn alongside the gecko export") } } + +// The names may be a node's: 50 renames of one oversized setting must leave +// one fixed-size entry for the inbound and warning lines of bounded length. +func TestGenHysteriaLinkSalamanderWarningStateIsBounded(t *testing.T) { + const id = 910004 + for i := range 50 { + name := strconv.Itoa(i) + strings.Repeat("k", 4096) + in := &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":{"password":"pw","` + name + `":1}}]}}`, + } + (&SubService{}).genHysteriaLink(in, "user") + } + entries := 0 + salamanderWarningSeen.Range(func(key, _ any) bool { + if strings.HasPrefix(fmt.Sprint(key), strconv.Itoa(id)) { + entries++ + } + return true + }) + if entries != 1 { + t.Fatalf("salamanderWarningSeen holds %d entries for one inbound, want 1", entries) + } + needle := "inbound " + strconv.Itoa(id) + ": salamander settings" + found := 0 + for _, line := range logger.GetLogs(100, "warning") { + if !strings.Contains(line, needle) { + continue + } + found++ + if len(line) > 512 { + t.Fatalf("warning line is %d bytes long, want at most 512", len(line)) + } + } + if found == 0 { + t.Fatal("renamed salamander settings did not warn at all") + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 4d6f95e5f..c1ed7bb93 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -31,6 +31,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" ) +// salamanderWarningSeen maps an inbound id to a digest of the setting names +// last warned about: a node picks the names, so the entry must stay fixed-size. var salamanderWarningSeen sync.Map // SubService provides business logic for generating subscription links and managing subscription data. @@ -1610,9 +1612,10 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // 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) + // Warn again when the set changes; log a bounded sample of the names. + digest := sha256.Sum256([]byte(strings.Join(extra, "\x00"))) + if prev, _ := salamanderWarningSeen.Swap(inbound.Id, digest); prev != digest { + logger.Warningf("SubService - inbound %d: salamander settings %.32q cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra[:min(len(extra), 8)]) } } if params["obfs"] == "" {