Files
3x-ui/internal/web/controller/client_dot_name_test.go
T
Benjamin Mitchell cd176faf4e fix(clients): reject dot names and recover legacy clients (#7062)
Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
2026-11-05 18:02:39 +01:00

217 lines
7.4 KiB
Go

package controller
import (
"bytes"
"encoding/json"
"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/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func seedLegacyDotClient(t *testing.T, email string) (*gin.Engine, model.ClientRecord, model.Inbound) {
t.Helper()
dir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dir)
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
db := database.GetDB()
rec := model.ClientRecord{Email: email, UUID: "11111111-2222-3333-4444-555555555555", SubID: "legacy-dot-sub", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatal(err)
}
settings, err := json.Marshal(map[string]any{
"clients": []model.Client{*rec.ToClient()},
"decryption": "none",
})
if err != nil {
t.Fatal(err)
}
ib := model.Inbound{Tag: "legacy-dot", Port: 443, Protocol: model.VLESS, Settings: string(settings), StreamSettings: `{"network":"tcp","security":"none"}`}
if err := db.Create(&ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{Email: email, InboundId: ib.Id, Up: 111, Down: 222, Enable: true}).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
c.Next()
})
NewClientController(router.Group("/base/panel/api/clients"))
return router, rec, ib
}
func dotClientRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, "/base/panel/api/clients/"+path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func assertDotClientSuccess(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if w.Code != http.StatusOK || !msg.Success {
t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
}
}
func TestClientQueryAliasRecoversLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
t.Run(email, func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
query := "?email=" + url.QueryEscape(email)
w := dotClientRequest(t, router, http.MethodGet, "get"+query, nil)
assertDotClientSuccess(t, w)
var full struct {
Obj struct {
Client model.ClientRecord `json:"client"`
InboundIDs []int `json:"inboundIds"`
UsedTraffic int64 `json:"usedTraffic"`
} `json:"obj"`
}
if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil {
t.Fatal(err)
}
if full.Obj.Client.Email != email || full.Obj.Client.UUID != rec.UUID || len(full.Obj.InboundIDs) != 1 || full.Obj.InboundIDs[0] != ib.Id || full.Obj.UsedTraffic != 333 {
t.Fatalf("unexpected legacy payload: %s", w.Body.String())
}
updated := rec.ToClient()
updated.Email = "restored-client"
w = dotClientRequest(t, router, http.MethodPost, "update"+query, updated)
assertDotClientSuccess(t, w)
db := database.GetDB()
var after model.ClientRecord
if err := db.First(&after, rec.Id).Error; err != nil {
t.Fatal(err)
}
if after.Email != updated.Email || after.UUID != rec.UUID || after.SubID != rec.SubID {
t.Fatalf("rename changed identity: %+v", after)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatal(err)
}
if len(settings.Clients) != 1 || settings.Clients[0].Email != updated.Email || settings.Clients[0].ID != rec.UUID {
t.Fatalf("rename lost inbound credentials: %s", ib.Settings)
}
var traffic xray.ClientTraffic
if err := db.Where("email = ?", updated.Email).First(&traffic).Error; err != nil {
t.Fatal(err)
}
if traffic.Up != 111 || traffic.Down != 222 {
t.Fatalf("rename lost usage: %+v", traffic)
}
})
}
}
func TestClientQueryAliasDeletesLegacyDotName(t *testing.T) {
for _, email := range []string{".", ".."} {
for _, keep := range []bool{false, true} {
t.Run(email+"/keep="+strconv.FormatBool(keep), func(t *testing.T) {
router, rec, ib := seedLegacyDotClient(t, email)
path := "del?email=" + url.QueryEscape(email)
if keep {
path += "&keepTraffic=1"
}
w := dotClientRequest(t, router, http.MethodPost, path, nil)
assertDotClientSuccess(t, w)
db := database.GetDB()
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientInbound{}).Where("client_id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("attachment remains: count=%d err=%v", count, err)
}
if err := db.First(&ib, ib.Id).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || len(settings.Clients) != 0 {
t.Fatalf("inbound client remains: %s err=%v", ib.Settings, err)
}
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&count).Error; err != nil {
t.Fatal(err)
}
var want int64
if keep {
want = 1
}
if count != want {
t.Fatalf("retained traffic count=%d keep=%v", count, keep)
}
})
}
}
}
func TestClientQueryAliasRequiresEmail(t *testing.T) {
router, _, _ := seedLegacyDotClient(t, ".")
for _, route := range []struct {
method, path string
}{
{http.MethodGet, "get"}, {http.MethodPost, "update"}, {http.MethodPost, "del"},
} {
w := dotClientRequest(t, router, route.method, route.path, map[string]any{"email": "must-not-be-used-as-target"})
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatal(err)
}
if msg.Success || !strings.Contains(msg.Msg, "client email is required") {
t.Fatalf("missing query accepted: %s", w.Body.String())
}
}
}
func TestClientPathEmailTakesPrecedenceOverQuery(t *testing.T) {
router, legacy, _ := seedLegacyDotClient(t, ".")
normal := model.ClientRecord{Email: "audit-client", UUID: "normal-uuid", SubID: "normal-sub", Enable: true}
db := database.GetDB()
if err := db.Create(&normal).Error; err != nil {
t.Fatal(err)
}
w := dotClientRequest(t, router, http.MethodPost, "del/audit-client?email=.", nil)
assertDotClientSuccess(t, w)
var count int64
if err := db.Model(&model.ClientRecord{}).Where("id = ?", normal.Id).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("path client remains: count=%d err=%v", count, err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", legacy.Id).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("query overrode the path identity: count=%d err=%v", count, err)
}
}