diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 670d2eb59..1463e3eae 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -90,7 +90,9 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri variants, closed := subReq.clientHostVariants(inbound, client, "clash") hasBoundClosed = hasBoundClosed || closed for _, variant := range variants { - render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) } + render := func() []map[string]any { + return s.budgetedProxies(subReq, variant.inbound(subReq, inbound), client, host) + } ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render}) } continue @@ -106,7 +108,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri injectExternalProxy(&clone, effectiveEps) effective = &clone } - proxies = append(proxies, s.getProxies(subReq, effective, client, host)...) + proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...) } } if subReq.hostsFirst { diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index c17edb2fa..3d75cdfdb 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -173,7 +173,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo hasBoundClosed = hasBoundClosed || closed for _, variant := range variants { render := func() subConfigEntry { - configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host) + configs := s.budgetedConfig(subReq, variant.inbound(subReq, inbound), client, host) return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order} } ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render}) @@ -191,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo injectExternalProxy(&clone, effectiveEps) effective = &clone } - inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...) + inboundConfigs = append(inboundConfigs, s.budgetedConfig(subReq, effective, client, host)...) } if len(inboundConfigs) > 0 { entries = append(entries, subConfigEntry{ diff --git a/internal/sub/render_budget.go b/internal/sub/render_budget.go new file mode 100644 index 000000000..8f59c9ed6 --- /dev/null +++ b/internal/sub/render_budget.go @@ -0,0 +1,70 @@ +package sub + +import ( + "encoding/json" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +// maxRenderedBytes bounds what one subscription response renders: every client and +// inbound of the subId repeats the remark and endpoints, so the size is a product. +const maxRenderedBytes = 8 << 20 + +// renderBudget counts rendered bytes against maxRenderedBytes. The zero value +// is a fresh budget. +type renderBudget int + +// take books n more bytes and reports whether they still fit. The caller drops +// what does not fit and stops rendering: a spent budget admits nothing else. +func (b *renderBudget) take(n int) bool { + if b.spent() { + return false + } + *b += renderBudget(n) + if b.spent() { + logger.Warningf("sub: rendering stopped at %d bytes, the remaining links are dropped", maxRenderedBytes) + return false + } + return true +} + +func (b *renderBudget) spent() bool { + return *b > maxRenderedBytes +} + +// keepLink books a rendered raw link, and its line break, against the request's budget. +func (s *SubService) keepLink(link string) bool { + return s.rendered.take(len(link) + 1) +} + +// budgetedConfig renders a client's JSON configs, keeping those the request's budget admits. +func (s *SubJsonService) budgetedConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage { + if subReq.rendered.spent() { + return nil + } + configs := s.getConfig(subReq, inbound, client, host) + for i, config := range configs { + if !subReq.rendered.take(len(config)) { + return configs[:i] + } + } + return configs +} + +// budgetedProxies renders a client's Clash proxies, keeping those the request's budget admits; +// the YAML is written only at the end, and its JSON form is about as long. +func (s *SubClashService) budgetedProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any { + if subReq.rendered.spent() { + return nil + } + proxies := s.getProxies(subReq, inbound, client, host) + for i, proxy := range proxies { + text, _ := json.Marshal(proxy) + if !subReq.rendered.take(len(text)) { + return proxies[:i] + } + } + return proxies +} diff --git a/internal/sub/render_budget_test.go b/internal/sub/render_budget_test.go new file mode 100644 index 000000000..bc55fe7c3 --- /dev/null +++ b/internal/sub/render_budget_test.go @@ -0,0 +1,71 @@ +package sub + +import ( + "fmt" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// budgetRepeats copies of budgetRemark are well past maxRenderedBytes. +var budgetRemark = strings.Repeat("r", 100_000) + +const budgetRepeats = 200 + +func renderedSubscriptionSizes(t *testing.T, subId string) map[string]int { + t.Helper() + base := NewSubService("") + links, _, _, _, err := base.GetSubs(subId, "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + jsonBody, _, err := NewSubJsonService("", "", "", "", base).GetJson(subId, "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + clashBody, _, err := NewSubClashService(false, "", false, base).GetClash(subId, "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + return map[string]int{"raw": len(strings.Join(links, "\n")), "json": len(jsonBody), "clash": len(clashBody)} +} + +// The final documents indent the booked JSON configs again and list every Clash +// proxy name a second time in the PROXY group, hence the factor of two. +func assertBoundedSubscription(t *testing.T, subId string) { + t.Helper() + for format, size := range renderedSubscriptionSizes(t, subId) { + if size < len(budgetRemark) || size > 2*maxRenderedBytes { + t.Errorf("%s subscription is %d bytes, want %d..%d", format, size, len(budgetRemark), 2*maxRenderedBytes) + } + } +} + +// Clients sharing a subId repeat the remark just like endpoints do, each in a +// fan-out of its own, so the response as a whole needs the bound too. +func TestSubscriptionBoundsRenderedSizeAcrossClients(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + ib := seedSubInbound(t, "s1", "many", 4431, 1, `{"network":"tcp","security":"none"}`) + var clients strings.Builder + for i := range budgetRepeats { + uuid, email := fmt.Sprintf("11111111-2222-4333-8444-%012d", i), fmt.Sprintf("c%d@e", i) + fmt.Fprintf(&clients, `{"id":%q,"email":%q,"subId":"s1","enable":true},`, uuid, email) + record := &model.ClientRecord{Email: email, SubID: "s1", UUID: uuid, Enable: true} + if err := db.Create(record).Error; err != nil { + t.Fatalf("seed client %s: %v", email, err) + } + if err := db.Create(&model.ClientInbound{ClientId: record.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed client_inbound %s: %v", email, err) + } + } + if err := db.Model(ib).Updates(map[string]any{ + "remark": budgetRemark, + "settings": `{"clients":[` + strings.TrimSuffix(clients.String(), ",") + `],"decryption":"none"}`, + }).Error; err != nil { + t.Fatalf("update inbound: %v", err) + } + assertBoundedSubscription(t, "s1") +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 5f9c8cd09..0a0d3cf64 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -79,6 +79,8 @@ type SubService struct { // with the clients array left out; generators read only inbound-level // fields (encryption, method, version, …) from it. settingsByInbound map[int]map[string]any + // rendered is what this request has put into its response so far. + rendered renderBudget // settingsCache is shared by this service's requests (ForRequest copies the pointer); only // subscription bodies read through it, never panel link rendering. settingsCache *inboundSettingsCache @@ -131,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) { s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil + s.rendered = 0 mode, _ := s.settingService.GetSubHostSortMode() s.hostsFirst = mode == "hosts-first" s.loadNodes() @@ -525,6 +528,10 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie if client.Enable { hasEnabledClient = true } + if s.rendered.spent() { + seenEmails[client.Email] = struct{}{} + continue + } var link string if s.hostsFirst { variants, closed := s.clientHostVariants(inbound, client, "raw") @@ -552,7 +559,7 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie } else { link = s.GetLink(inbound, client.Email) } - if !s.hostsFirst { + if !s.hostsFirst && s.keepLink(link) { result = append(result, link) } emails = append(emails, client.Email) @@ -561,7 +568,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie } if s.hostsFirst { for _, render := range orderedSubValues(ordered) { - result = append(result, render()) + if s.rendered.spent() { + break + } + if link := render(); s.keepLink(link) { + result = append(result, link) + } } } for _, ext := range externalLinks {