mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
fix(node): bound what one node inbound adds to a subscription
A subscription renders an inbound's stream and remark once per client and per external proxy, and every client holding its subId, all values a node chooses: an inbound with a megabyte stream and thousands of external proxies, or many clients sharing one subId, made a single unauthenticated request cost gigabytes of memory and minutes of CPU. A reported inbound whose streamSettings exceeds 64 KiB, sniffing 16 KiB or remark 1 KiB, or that lists more than 64 external proxies, is no longer taken in (an existing one keeps its stored values), and a node can no longer give a client a subId another client already holds, including another one in the same snapshot.
This commit is contained in:
@@ -708,6 +708,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
mergedCentral := make(map[int]struct{}, len(central))
|
||||
|
||||
snapTags := make(map[string]struct{}, len(snap.Inbounds))
|
||||
oversizeTags := map[string]string{}
|
||||
for snapIdx, snapIb := range snap.Inbounds {
|
||||
if snapIb == nil {
|
||||
continue
|
||||
@@ -759,6 +760,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
mergedCentral[c.Id] = struct{}{}
|
||||
}
|
||||
if reason := oversizeNodeInbound(snapIb); reason != "" {
|
||||
oversizeTags[snapIb.Tag] = reason
|
||||
snap.Inbounds[snapIdx] = nil
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
if dirty {
|
||||
continue
|
||||
@@ -915,6 +921,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
}
|
||||
|
||||
logOversizeNodeInbounds(nodeID, oversizeTags)
|
||||
if hostGroupsSkipped > 0 {
|
||||
logger.Warningf(
|
||||
"setRemoteTraffic: node %d: left out %d host group adoption(s) — a group is limited to %d bytes and the node's inbounds to %d host rows",
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -42,6 +43,9 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
storedSubID: map[string]string{},
|
||||
}
|
||||
subIDByEmail := map[string]string{}
|
||||
// A subId selects a whole subscription, so within one snapshot it may name one client only.
|
||||
firstEmailBySub := map[string]string{}
|
||||
sharesSubID := map[string]struct{}{}
|
||||
for _, snapIb := range snap.Inbounds {
|
||||
if snapIb == nil {
|
||||
continue
|
||||
@@ -63,6 +67,11 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
var subID string
|
||||
_ = json.Unmarshal(c.SubID, &subID)
|
||||
subIDByEmail[c.Email] = subID
|
||||
if first, taken := firstEmailBySub[subID]; subID != "" && taken && !strings.EqualFold(first, c.Email) {
|
||||
sharesSubID[c.Email] = struct{}{}
|
||||
} else if subID != "" && !taken {
|
||||
firstEmailBySub[subID] = c.Email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,13 +114,10 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
holders = append(holders, rows...)
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(records)+len(holders))
|
||||
ids := make([]int, 0, len(records))
|
||||
for i := range records {
|
||||
ids = append(ids, records[i].Id)
|
||||
}
|
||||
for i := range holders {
|
||||
ids = append(ids, holders[i].Id)
|
||||
}
|
||||
scopes, err := clientLinkScopes(tx, nodeID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -120,9 +126,12 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
for _, h := range holders {
|
||||
holdersBySub[h.SubID] = append(holdersBySub[h.SubID], h)
|
||||
}
|
||||
subHeldElsewhere := func(email, subID string) bool {
|
||||
subIDTaken := func(email, subID string) bool {
|
||||
if _, shared := sharesSubID[email]; shared {
|
||||
return true
|
||||
}
|
||||
for _, h := range holdersBySub[subID] {
|
||||
if !strings.EqualFold(h.Email, email) && scopes[h.Id] != (clientLinkScope{here: true}) {
|
||||
if !strings.EqualFold(h.Email, email) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -134,8 +143,8 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
if !exact {
|
||||
if _, variant := byLower[strings.ToLower(email)]; variant {
|
||||
claims.refused[email] = "another client's email differs from it only in case"
|
||||
} else if subID != "" && subHeldElsewhere(email, subID) {
|
||||
claims.refused[email] = "its subId belongs to a client this node does not host"
|
||||
} else if subID != "" && subIDTaken(email, subID) {
|
||||
claims.refused[email] = "its subId belongs to another client"
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -147,7 +156,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot
|
||||
claims.refused[email] = "it is attached only to inbounds this node does not host"
|
||||
case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID):
|
||||
claims.refused[email] = "it is detached from every inbound on the master"
|
||||
case subID != rec.SubID && subID != "" && subHeldElsewhere(email, subID):
|
||||
case subID != rec.SubID && subID != "" && subIDTaken(email, subID):
|
||||
claims.storedSubID[email] = rec.SubID
|
||||
}
|
||||
}
|
||||
@@ -283,3 +292,45 @@ func sharedRecordWouldChange(rec *model.ClientRecord, reported *model.Client) bo
|
||||
keepLinkOwnedFields(&merged, rec, false)
|
||||
return !reflect.DeepEqual(merged, *rec)
|
||||
}
|
||||
|
||||
// A node's stream, sniffing and remark are rendered into every subscription link of the
|
||||
// inbound, once per client and external proxy, so their size bounds a request's cost.
|
||||
const (
|
||||
maxNodeStreamSettingsBytes = 64 << 10
|
||||
maxNodeSniffingBytes = 16 << 10
|
||||
maxNodeRemarkBytes = 1 << 10
|
||||
maxNodeExternalProxies = 64
|
||||
)
|
||||
|
||||
// reportedOversizeInbounds holds, per node, the tags its last snapshot left out as oversized.
|
||||
var reportedOversizeInbounds sync.Map
|
||||
|
||||
// oversizeNodeInbound names the field that keeps a reported inbound from being taken in.
|
||||
func oversizeNodeInbound(ib *model.Inbound) string {
|
||||
switch {
|
||||
case len(ib.StreamSettings) > maxNodeStreamSettingsBytes:
|
||||
return fmt.Sprintf("streamSettings exceeds %d bytes", maxNodeStreamSettingsBytes)
|
||||
case len(ib.Sniffing) > maxNodeSniffingBytes:
|
||||
return fmt.Sprintf("sniffing exceeds %d bytes", maxNodeSniffingBytes)
|
||||
case len(ib.Remark) > maxNodeRemarkBytes:
|
||||
return fmt.Sprintf("remark exceeds %d bytes", maxNodeRemarkBytes)
|
||||
}
|
||||
var stream struct {
|
||||
ExternalProxy []json.RawMessage `json:"externalProxy"`
|
||||
}
|
||||
if json.Unmarshal([]byte(ib.StreamSettings), &stream) == nil && len(stream.ExternalProxy) > maxNodeExternalProxies {
|
||||
return fmt.Sprintf("more than %d external proxies", maxNodeExternalProxies)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logOversizeNodeInbounds(nodeID int, tags map[string]string) {
|
||||
prev, _ := reportedOversizeInbounds.Load(nodeID)
|
||||
logged, _ := prev.(map[string]string)
|
||||
for tag, reason := range tags {
|
||||
if _, seen := logged[tag]; !seen {
|
||||
logger.Warningf("setRemoteTraffic: node %d reported inbound %.64q, not taking it in: %s", nodeID, tag, reason)
|
||||
}
|
||||
}
|
||||
reportedOversizeInbounds.Store(nodeID, tags)
|
||||
}
|
||||
|
||||
@@ -383,3 +383,59 @@ func TestNodeRefusedClaimLogStateIsBounded(t *testing.T) {
|
||||
t.Fatalf("refused-claim log state holds %d emails for one node, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A subscription renders every client holding its subId, so a node creating many clients
|
||||
// under one subId multiplied the cost of a single request without bound.
|
||||
func TestNodeClientsCannotShareASubID(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in",
|
||||
`{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"b@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true}]}`)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in",
|
||||
`{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true},{"email":"d@x","subId":"one-sub","enable":true}]}`)
|
||||
|
||||
if n := countClientsWhere(t, db, "sub_id = ?", "one-sub"); n != 1 {
|
||||
t.Fatalf("%d records hold one subId, want 1", n)
|
||||
}
|
||||
if !nodeHostsClient(t, db, "n1-in", "c@x") {
|
||||
t.Fatal("a client with its own subId was not adopted")
|
||||
}
|
||||
}
|
||||
|
||||
// Every reported stream, sniffing and remark is rendered once per client and external
|
||||
// proxy of a subscription, so an oversized one let a node make one request cost gigabytes.
|
||||
func TestNodeOversizeInboundIsNotTakenIn(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
createNodeInbound(t, db, 1, "n1-in", 41001)
|
||||
|
||||
proxies := make([]string, maxNodeExternalProxies+1)
|
||||
for i := range proxies {
|
||||
proxies[i] = fmt.Sprintf(`{"dest":"h%d.example","port":443,"forceTls":"same"}`, i)
|
||||
}
|
||||
stream := `{"network":"tcp","externalProxy":[` + strings.Join(proxies, ",") + `]}`
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
||||
{Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, Settings: `{"clients":[]}`, StreamSettings: stream},
|
||||
{Tag: "n1-new", Enable: true, Port: 41002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Remark: strings.Repeat("r", maxNodeRemarkBytes+1)},
|
||||
}}
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
|
||||
var stored model.Inbound
|
||||
if err := db.Where("tag = ?", "n1-in").First(&stored).Error; err != nil {
|
||||
t.Fatalf("the oversized report removed the existing inbound: %v", err)
|
||||
}
|
||||
if strings.Contains(stored.StreamSettings, "externalProxy") {
|
||||
t.Fatal("an inbound with too many external proxies was taken in")
|
||||
}
|
||||
var created int64
|
||||
db.Model(&model.Inbound{}).Where("tag = ?", "n1-new").Count(&created)
|
||||
if created != 0 {
|
||||
t.Fatal("an inbound with an oversized remark was adopted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user