mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(api): cap the client traffic list a master pushes by entry count
pushClientTraffics decoded the pushed list whole, and each "{}" became a
full traffic row, so a body under the size cap could cost the panel
close to a gigabyte. The list is now decoded entry by entry and refused
past 262144 entries. The capped decoder the node runtime already used
moves to json_util so both share it.
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package json_util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrListTooLong is returned by DecodeCappedList for a list past its entry limit.
|
||||||
|
var ErrListTooLong = errors.New("list exceeds entry limit")
|
||||||
|
|
||||||
|
// DecodeCappedList decodes a JSON array entry by entry and stops at the first entry past
|
||||||
|
// maxEntries: each "{}" becomes a whole T, so a byte cap alone does not bound the result.
|
||||||
|
func DecodeCappedList[T any](raw []byte, maxEntries int) ([]T, error) {
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
if tok, err := dec.Token(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if delim, ok := tok.(json.Delim); !ok || delim != '[' {
|
||||||
|
return nil, fmt.Errorf("expected a JSON array, got %v", tok)
|
||||||
|
}
|
||||||
|
var out []T
|
||||||
|
for dec.More() {
|
||||||
|
if len(out) >= maxEntries {
|
||||||
|
return nil, fmt.Errorf("%w (%d)", ErrListTooLong, maxEntries)
|
||||||
|
}
|
||||||
|
var item T
|
||||||
|
if err := dec.Decode(&item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
@@ -395,18 +396,26 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPushedClientTraffics bounds one push: each entry decodes into a whole traffic row.
|
||||||
|
const maxPushedClientTraffics = 1 << 18
|
||||||
|
|
||||||
// pushClientTraffics receives a master panel's aggregated per-client usage
|
// pushClientTraffics receives a master panel's aggregated per-client usage
|
||||||
// (see InboundService.AcceptGlobalTraffic for the storage semantics).
|
// (see InboundService.AcceptGlobalTraffic for the storage semantics).
|
||||||
func (a *InboundController) pushClientTraffics(c *gin.Context) {
|
func (a *InboundController) pushClientTraffics(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
MasterGuid string `json:"masterGuid"`
|
MasterGuid string `json:"masterGuid"`
|
||||||
Traffics []*xray.ClientTraffic `json:"traffics"`
|
Traffics json.RawMessage `json:"traffics"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil {
|
traffics, err := json_util.DecodeCappedList[*xray.ClientTraffic](req.Traffics, maxPushedClientTraffics)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, traffics); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,3 +104,24 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each "{}" of the pushed list decodes into a whole traffic row, so a body under the size
|
||||||
|
// cap could still cost the panel close to a gigabyte; the list is capped by entry count.
|
||||||
|
func TestPushClientTrafficsCapsTheListByEntryCount(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(func(c *gin.Context) {
|
||||||
|
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
NewInboundController(engine.Group("/panel/api/inbounds"))
|
||||||
|
body := `{"masterGuid":"m","traffics":[` + strings.TrimSuffix(strings.Repeat("{},", maxPushedClientTraffics+1), ",") + `]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/pushClientTraffics", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
if !strings.Contains(w.Body.String(), `"success":false`) {
|
||||||
|
t.Fatalf("an over-long traffic list was accepted: %.200s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||||
@@ -423,7 +424,7 @@ func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list, err := decodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds)
|
list, err := json_util.DecodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||||
}
|
}
|
||||||
@@ -435,7 +436,7 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
list, err := decodeCappedList[struct {
|
list, err := json_util.DecodeCappedList[struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
}](env.Obj, maxNodeInbounds)
|
}](env.Obj, maxNodeInbounds)
|
||||||
@@ -768,7 +769,7 @@ func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out, err := decodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants)
|
out, err := json_util.DecodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode descendants: %w", err)
|
return nil, fmt.Errorf("decode descendants: %w", err)
|
||||||
}
|
}
|
||||||
@@ -829,7 +830,7 @@ func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
groups, err := decodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups)
|
groups, err := json_util.DecodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode host groups: %w", err)
|
return nil, fmt.Errorf("decode host groups: %w", err)
|
||||||
}
|
}
|
||||||
@@ -843,7 +844,7 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if snap.Inbounds, err = decodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil {
|
if snap.Inbounds, err = json_util.DecodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil {
|
||||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,7 +1004,7 @@ func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ips, err := decodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs)
|
ips, err := json_util.DecodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode client ips: %w", err)
|
return nil, fmt.Errorf("decode client ips: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package runtime
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Per-endpoint caps on node-reported lists: each "{}" decodes into a whole struct, so the
|
// Per-endpoint caps on node-reported lists: each "{}" decodes into a whole struct, so the
|
||||||
@@ -19,34 +20,7 @@ const (
|
|||||||
maxNodeMapValues = 1 << 20
|
maxNodeMapValues = 1 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
var errRemoteListTooLong = errors.New("remote list exceeds entry limit")
|
var errRemoteListTooLong = json_util.ErrListTooLong
|
||||||
|
|
||||||
// decodeCappedList decodes a JSON array entry by entry and stops at the first entry
|
|
||||||
// past maxEntries; null decodes to a nil slice.
|
|
||||||
func decodeCappedList[T any](raw json.RawMessage, maxEntries int) ([]T, error) {
|
|
||||||
raw = bytes.TrimSpace(raw)
|
|
||||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
if tok, err := dec.Token(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if delim, ok := tok.(json.Delim); !ok || delim != '[' {
|
|
||||||
return nil, fmt.Errorf("expected a JSON array, got %v", tok)
|
|
||||||
}
|
|
||||||
var out []T
|
|
||||||
for dec.More() {
|
|
||||||
if len(out) >= maxEntries {
|
|
||||||
return nil, fmt.Errorf("%w (%d)", errRemoteListTooLong, maxEntries)
|
|
||||||
}
|
|
||||||
var item T
|
|
||||||
if err := dec.Decode(&item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, item)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeCappedValues unmarshals raw into v after checking it holds at most maxValues
|
// decodeCappedValues unmarshals raw into v after checking it holds at most maxValues
|
||||||
// JSON values in all, nested ones included.
|
// JSON values in all, nested ones included.
|
||||||
|
|||||||
Reference in New Issue
Block a user