diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 1463e3eae..123ecb92f 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -15,7 +15,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/tuic" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -670,8 +669,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model return nil } - var inboundSettings map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + inboundSettings := subReq.linkSettings(inbound) secretKey, _ := inboundSettings["secretKey"].(string) proxy := map[string]any{ @@ -723,21 +721,18 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model } func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any { - inst, ok := tuic.InstanceFromInbound(inbound) - if !ok { + inst := subReq.tuicInstanceForLink(inbound) + if inst == nil { return nil } uuid := client.ID password := client.Password - for _, c := range inst.Clients { - if c.Email == client.Email { - if uuid == "" { - uuid = c.UUID - } - if password == "" { - password = c.Password - } - break + if c := inst.byEmail[client.Email]; c != nil { + if uuid == "" { + uuid = c.UUID + } + if password == "" { + password = c.Password } } if uuid == "" || password == "" { @@ -779,15 +774,9 @@ func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbo // amneziaWGClientAddresses prefers this inbound's own settings entry over the // shared clients.wg_allowed_ips column, which for an identity attached to both // a wireguard and an amneziawg inbound holds the other one's address. -func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string { - for i := range settingsClients { - if !strings.EqualFold(settingsClients[i].Email, client.Email) { - continue - } - if len(settingsClients[i].AllowedIPs) > 0 { - return settingsClients[i].AllowedIPs - } - break +func amneziaWGClientAddresses(subReq *SubService, inbound *model.Inbound, client model.Client) []string { + if own, ok := subReq.clientForLink(inbound, client.Email); ok && len(own.AllowedIPs) > 0 { + return own.AllowedIPs } return client.AllowedIPs } @@ -811,11 +800,10 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model return nil } - var parsed amneziawg.InboundSettings - if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { + server := subReq.amneziaWGServerForLink(inbound) + if server == nil { return nil } - server := parsed.Server proxy := map[string]any{ "name": subReq.endpointRemark(inbound, client.Email, ep, ""), @@ -836,7 +824,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model proxy["persistent-keepalive"] = ka } - for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) { + for _, addr := range amneziaWGClientAddresses(subReq, inbound, client) { ip := stripCIDR(addr) if ip == "" { continue diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index e01ff30ea..5bf9cbb24 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -6,6 +6,7 @@ import ( "net" "net/url" "path/filepath" + "runtime" "strings" "testing" @@ -2077,3 +2078,155 @@ func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) { t.Fatalf("child-1 must carry its front's TLS state: %s", links[0]) } } + +// seedSharedSubIdInbound stores one inbound whose n clients all carry subId, the +// shape a node merge produces (the admin API keeps a subId unique per client). +func seedSharedSubIdInbound(t *testing.T, protocol model.Protocol, subId string, n int, serverJSON string, clientJSON func(i int) string, record func(i int) model.ClientRecord) *model.Inbound { + t.Helper() + entries := make([]string, n) + records := make([]model.ClientRecord, n) + for i := range entries { + entries[i] = clientJSON(i) + records[i] = record(i) + records[i].SubID = subId + records[i].Enable = true + } + db := database.GetDB() + ib := &model.Inbound{ + UserId: 1, Tag: string(protocol), Enable: true, Listen: "203.0.113.5", Port: 443, Protocol: protocol, + Remark: string(protocol), + Settings: fmt.Sprintf(`{%s,"clients":[%s]}`, serverJSON, strings.Join(entries, ",")), + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create %s inbound: %v", protocol, err) + } + if err := db.CreateInBatches(&records, 100).Error; err != nil { + t.Fatalf("create clients: %v", err) + } + links := make([]model.ClientInbound, n) + for i := range records { + links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id} + } + if err := db.CreateInBatches(&links, 500).Error; err != nil { + t.Fatalf("link clients: %v", err) + } + return ib +} + +// A subscription of n clients of one inbound must decode that inbound's settings +// once, not once per link: with the clients array inside, n decodes grow with n². +func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { + const n = 1000 + const subId = "shared-sub-id" + const maxAllocated = 128 << 20 + serverPriv, serverPub := mustWireguardKeypair(t) + clientPriv, _ := mustWireguardKeypair(t) + + email := func(i int) string { return fmt.Sprintf("user-%04d@sub", i) } + uuid := func(i int) string { return fmt.Sprintf("11111111-2222-4333-8444-%012d", i) } + seed := map[model.Protocol]func(t *testing.T) *model.Inbound{ + model.TUIC: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.TUIC, subId, n, `"server":{"certificate":"/c","private_key":"/k"}`, + func(i int) string { + return fmt.Sprintf(`{"uuid":%q,"password":"pw","email":%q,"enable":true}`, uuid(i), email(i)) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), UUID: uuid(i), Password: "pw"} + }) + }, + model.AmneziaWG: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.AmneziaWG, subId, n, fmt.Sprintf(`"server":{"privateKey":%q,"publicKey":%q,"mtu":1420}`, serverPriv, serverPub), + func(i int) string { + return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} + }) + }, + model.WireGuard: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.WireGuard, subId, n, fmt.Sprintf(`"secretKey":%q,"mtu":1420`, serverPriv), + func(i int) string { + return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} + }) + }, + model.Hysteria: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.Hysteria, subId, n, `"version":2`, + func(i int) string { + return fmt.Sprintf(`{"email":%q,"auth":"auth","enable":true}`, email(i)) + }, + func(i int) model.ClientRecord { return model.ClientRecord{Email: email(i), Auth: "auth"} }) + }, + } + + raw := func(t *testing.T) int { + links, _, _, _, err := NewSubService("").GetSubs(subId, "sub.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + for _, link := range links { + if link == "" { + t.Fatal("GetSubs returned an empty link") + } + } + return len(links) + } + clash := func(t *testing.T) int { + out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + var doc struct { + Proxies []map[string]any `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("parse clash yaml: %v", err) + } + return len(doc.Proxies) + } + jsonSub := func(t *testing.T) int { + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "sub.example.com", true) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var configs []json.RawMessage + if err := json.Unmarshal([]byte(out), &configs); err != nil { + t.Fatalf("parse json subscription: %v", err) + } + return len(configs) + } + + cases := []struct { + name string + protocol model.Protocol + fetch func(t *testing.T) int + }{ + {"raw tuic", model.TUIC, raw}, + {"raw amneziawg", model.AmneziaWG, raw}, + {"clash tuic", model.TUIC, clash}, + {"clash amneziawg", model.AmneziaWG, clash}, + {"clash wireguard", model.WireGuard, clash}, + {"json wireguard", model.WireGuard, jsonSub}, + {"json hysteria", model.Hysteria, jsonSub}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + initSubDB(t) + seed[tc.protocol](t) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + got := tc.fetch(t) + runtime.ReadMemStats(&after) + + if got != n { + t.Fatalf("entries = %d, want %d", got, n) + } + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > maxAllocated { + t.Fatalf("%d entries allocated %d MiB, want at most %d MiB", n, allocated>>20, maxAllocated>>20) + } + }) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 3d75cdfdb..c608b928d 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -731,11 +731,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c case "trojan", "shadowsocks": newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux))) case "hysteria": - newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) + newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux))) case "masque": newOutbounds = append(newOutbounds, s.genMasque(inbound, newStream, client)) case "wireguard": - wgOutbound := s.genWireguard(inbound, client) + wgOutbound := s.genWireguard(subReq, inbound, client) if wgOutbound == nil { continue } @@ -1037,7 +1037,7 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s return result } -func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { +func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{ Protocol: string(inbound.Protocol), Tag: "proxy", @@ -1047,9 +1047,8 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, outbound.Mux = json_util.RawMessage(mux) } - var settings, stream map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &settings) - version, _ := settings["version"].(float64) + var stream map[string]any + version, _ := subReq.linkSettings(inbound)["version"].(float64) outbound.Settings = map[string]any{ "version": int(version), "address": inbound.Listen, @@ -1115,13 +1114,12 @@ func (s *SubJsonService) genMasque(inbound *model.Inbound, newStream map[string] // mirroring genWireguardLink: the peer public key is derived from the inbound // secretKey, the client owns the private key / tunnel address / pre-shared key, // and the peer routes the full tunnel. Returns nil when the client has no key. -func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage { +func (s *SubJsonService) genWireguard(subReq *SubService, inbound *model.Inbound, client model.Client) json_util.RawMessage { if client.PrivateKey == "" { return nil } - var inboundSettings map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + inboundSettings := subReq.linkSettings(inbound) secretKey, _ := inboundSettings["secretKey"].(string) peer := map[string]any{ diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index dac398a25..4a03af723 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -520,7 +520,7 @@ func TestSubJsonServiceWireguard(t *testing.T) { AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, } - raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client) + raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client) if raw == nil { t.Fatal("genWireguard returned nil for a valid wireguard client") } @@ -564,7 +564,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) { inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} client := model.Client{Email: "user"} - if raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client); raw != nil { + if raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client); raw != nil { t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw) } } diff --git a/internal/sub/service.go b/internal/sub/service.go index 0f6ea464d..23523b926 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -91,6 +91,17 @@ type SubService struct { hostGroupOrders map[string]hostEntryOrder hostsFirst bool sharedGroupSni map[string]string + // tuicByInbound and amneziaWGServerByInbound are the typed equivalents of + // settingsByInbound: one decode per request, not one per link of a subId. + tuicByInbound map[int]*tuicLinkInstance + amneziaWGServerByInbound map[int]*amneziawg.ServerSettings +} + +// tuicLinkInstance indexes a decoded TUIC inbound's clients by email +// (first occurrence wins, matching the settings-JSON scan it replaces). +type tuicLinkInstance struct { + tuic.Instance + byEmail map[string]*tuic.TuicClientSettings } // NewSubService creates a new subscription service with the given configuration. @@ -134,6 +145,8 @@ func (s *SubService) PrepareForRequest(host string) { s.fullyPrimedInbounds = map[int]bool{} s.settingsByInbound = map[int]map[string]any{} s.socketFronts = nil + s.tuicByInbound = map[int]*tuicLinkInstance{} + s.amneziaWGServerByInbound = map[int]*amneziawg.ServerSettings{} s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil @@ -257,6 +270,55 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any { return out } +// tuicInstanceForLink returns the inbound's tuic.Instance decoded once per +// request, or nil when the settings do not decode. +func (s *SubService) tuicInstanceForLink(inbound *model.Inbound) *tuicLinkInstance { + if inbound.Id > 0 { + if cached, ok := s.tuicByInbound[inbound.Id]; ok { + return cached + } + } + var out *tuicLinkInstance + if inst, ok := tuic.InstanceFromInbound(inbound); ok { + out = &tuicLinkInstance{Instance: inst, byEmail: make(map[string]*tuic.TuicClientSettings, len(inst.Clients))} + for i := range out.Clients { + if _, exists := out.byEmail[out.Clients[i].Email]; !exists { + out.byEmail[out.Clients[i].Email] = &out.Clients[i] + } + } + } + if inbound.Id > 0 { + if s.tuicByInbound == nil { + s.tuicByInbound = map[int]*tuicLinkInstance{} + } + s.tuicByInbound[inbound.Id] = out + } + return out +} + +// amneziaWGServerForLink returns the inbound's AmneziaWG server block decoded once +// per request without its clients (those resolve via clientForLink), or nil. +func (s *SubService) amneziaWGServerForLink(inbound *model.Inbound) *amneziawg.ServerSettings { + if inbound.Id > 0 { + if cached, ok := s.amneziaWGServerByInbound[inbound.Id]; ok { + return cached + } + } + var parsed struct { + Server *amneziawg.ServerSettings `json:"server"` + } + if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil { + parsed.Server = nil + } + if inbound.Id > 0 { + if s.amneziaWGServerByInbound == nil { + s.amneziaWGServerByInbound = map[int]*amneziawg.ServerSettings{} + } + s.amneziaWGServerByInbound[inbound.Id] = parsed.Server + } + return parsed.Server +} + // loadRemarkSettings populates the per-request remark formatting state so // every subscription format — raw, JSON, Clash — renders remarks the same way // (the date formatter reads datepicker). Loading it only in getSubs left @@ -965,17 +1027,11 @@ func (s *SubService) genTuicLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.TUIC { return "" } - inst, ok := tuic.InstanceFromInbound(inbound) - if !ok { + inst := s.tuicInstanceForLink(inbound) + if inst == nil { return "" } - var client *tuic.TuicClientSettings - for _, c := range inst.Clients { - if c.Email == email { - client = &c - break - } - } + client := inst.byEmail[email] if client == nil && len(inst.Clients) > 0 && email == "" { client = &inst.Clients[0] } @@ -1197,11 +1253,10 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri if inbound.Protocol != model.AmneziaWG { return "" } - var parsed amneziawg.InboundSettings - if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { + server := s.amneziaWGServerForLink(inbound) + if server == nil { return "" } - server := parsed.Server resolved, ok := s.clientForLink(inbound, email) if !ok || resolved.PrivateKey == "" {