mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 13:15:01 +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")
|
variants, closed := subReq.clientHostVariants(inbound, client, "clash")
|
||||||
hasBoundClosed = hasBoundClosed || closed
|
hasBoundClosed = hasBoundClosed || closed
|
||||||
for _, variant := range variants {
|
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})
|
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -106,7 +108,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
|||||||
injectExternalProxy(&clone, effectiveEps)
|
injectExternalProxy(&clone, effectiveEps)
|
||||||
effective = &clone
|
effective = &clone
|
||||||
}
|
}
|
||||||
proxies = append(proxies, s.getProxies(subReq, effective, client, host)...)
|
proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subReq.hostsFirst {
|
if subReq.hostsFirst {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
hasBoundClosed = hasBoundClosed || closed
|
hasBoundClosed = hasBoundClosed || closed
|
||||||
for _, variant := range variants {
|
for _, variant := range variants {
|
||||||
render := func() subConfigEntry {
|
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}
|
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
|
||||||
}
|
}
|
||||||
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
|
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)
|
injectExternalProxy(&clone, effectiveEps)
|
||||||
effective = &clone
|
effective = &clone
|
||||||
}
|
}
|
||||||
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...)
|
inboundConfigs = append(inboundConfigs, s.budgetedConfig(subReq, effective, client, host)...)
|
||||||
}
|
}
|
||||||
if len(inboundConfigs) > 0 {
|
if len(inboundConfigs) > 0 {
|
||||||
entries = append(entries, subConfigEntry{
|
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
|
// with the clients array left out; generators read only inbound-level
|
||||||
// fields (encryption, method, version, …) from it.
|
// fields (encryption, method, version, …) from it.
|
||||||
settingsByInbound map[int]map[string]any
|
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
|
// settingsCache is shared by this service's requests (ForRequest copies the pointer); only
|
||||||
// subscription bodies read through it, never panel link rendering.
|
// subscription bodies read through it, never panel link rendering.
|
||||||
settingsCache *inboundSettingsCache
|
settingsCache *inboundSettingsCache
|
||||||
@@ -131,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) {
|
|||||||
s.hostsByInbound = nil
|
s.hostsByInbound = nil
|
||||||
s.hostGroupOrders = nil
|
s.hostGroupOrders = nil
|
||||||
s.sharedGroupSni = nil
|
s.sharedGroupSni = nil
|
||||||
|
s.rendered = 0
|
||||||
mode, _ := s.settingService.GetSubHostSortMode()
|
mode, _ := s.settingService.GetSubHostSortMode()
|
||||||
s.hostsFirst = mode == "hosts-first"
|
s.hostsFirst = mode == "hosts-first"
|
||||||
s.loadNodes()
|
s.loadNodes()
|
||||||
@@ -525,6 +528,10 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
|||||||
if client.Enable {
|
if client.Enable {
|
||||||
hasEnabledClient = true
|
hasEnabledClient = true
|
||||||
}
|
}
|
||||||
|
if s.rendered.spent() {
|
||||||
|
seenEmails[client.Email] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
var link string
|
var link string
|
||||||
if s.hostsFirst {
|
if s.hostsFirst {
|
||||||
variants, closed := s.clientHostVariants(inbound, client, "raw")
|
variants, closed := s.clientHostVariants(inbound, client, "raw")
|
||||||
@@ -552,7 +559,7 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
|||||||
} else {
|
} else {
|
||||||
link = s.GetLink(inbound, client.Email)
|
link = s.GetLink(inbound, client.Email)
|
||||||
}
|
}
|
||||||
if !s.hostsFirst {
|
if !s.hostsFirst && s.keepLink(link) {
|
||||||
result = append(result, link)
|
result = append(result, link)
|
||||||
}
|
}
|
||||||
emails = append(emails, client.Email)
|
emails = append(emails, client.Email)
|
||||||
@@ -561,7 +568,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
|||||||
}
|
}
|
||||||
if s.hostsFirst {
|
if s.hostsFirst {
|
||||||
for _, render := range orderedSubValues(ordered) {
|
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 {
|
for _, ext := range externalLinks {
|
||||||
|
|||||||
Reference in New Issue
Block a user