Files
3x-ui/internal/web/controller/inbound_node_sync_test.go
T
MHSanaei fe5d704d67 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.
2026-10-31 14:25:21 +01:00

128 lines
4.6 KiB
Go

package controller
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"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/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
// A sub-node stores whatever the master pushes. A master row whose certificate
// predates the TLS guard must still land, or the node silently falls out of sync.
func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
prev := runtime.GetManager()
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(prev) })
for name, scope := range map[string]string{
"node-sync": model.ApiScopeNodeSync,
"node-admin": model.ApiScopeNodeAdmin,
"admin": model.ApiScopeAdmin,
} {
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(name + "-token"), Enabled: true, Scope: scope}
if err := database.GetDB().Create(row).Error; err != nil {
t.Fatalf("seed %s token: %v", name, err)
}
}
engine := gin.New()
a := &APIController{}
api := engine.Group("/panel/api")
api.Use(a.checkAPIAuth, a.enforceTokenScope)
NewInboundController(api.Group("/inbounds"))
const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
add := func(t *testing.T, token string, port int) string {
t.Helper()
form := url.Values{
"protocol": {"vless"},
"port": {strconv.Itoa(port)},
"tag": {"tls-legacy-" + strconv.Itoa(port)},
"enable": {"true"},
"settings": {`{"clients":[]}`},
"streamSettings": {legacyStream},
}
req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/add", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w.Body.String()
}
rows := func(t *testing.T, tag string) int64 {
t.Helper()
var n int64
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).Count(&n).Error; err != nil {
t.Fatalf("count %s: %v", tag, err)
}
return n
}
t.Run("a master push lands on the node", func(t *testing.T) {
body := add(t, "node-sync-token", 45001)
if !strings.Contains(body, `"success":true`) {
t.Fatalf("node-sync add rejected: %s", body)
}
if got := rows(t, "tls-legacy-45001"); got != 1 {
t.Fatalf("stored rows = %d, want 1", got)
}
})
t.Run("a node admin push keeps node sync semantics", func(t *testing.T) {
body := add(t, "node-admin-token", 45003)
if !strings.Contains(body, `"success":true`) {
t.Fatalf("node-admin add rejected: %s", body)
}
if got := rows(t, "tls-legacy-45003"); got != 1 {
t.Fatalf("stored rows = %d, want 1", got)
}
})
t.Run("an operator token is still held to the guard", func(t *testing.T) {
body := add(t, "admin-token", 45002)
if !strings.Contains(body, `"success":false`) || !strings.Contains(body, "TLS") {
t.Fatalf("admin add should fail on TLS, got: %s", body)
}
if got := rows(t, "tls-legacy-45002"); got != 0 {
t.Fatalf("stored rows = %d, want 0", got)
}
})
}
// 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())
}
}