mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
fix(sub): bound what one subscription response renders
A subscription's size is the product of its clients and inbounds with each one's remark and endpoints, and nothing bounded that product: data adopted from a node could make one unauthenticated request render gigabytes and run the panel out of memory. Each request now carries an 8 MiB budget that the raw, JSON and Clash outputs charge as they materialize a client's links, configs or proxies. Rendering stops once it is spent, with one warning, and anything below the budget is unchanged.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+14
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user