mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
fix(clients): preserve enable on portable import (#6481)
* fix(clients): preserve enable on portable import Stop BulkCreate and orphan ImportClients from forcing enable=true, and restate Enable=false after GORM Create (clients.enable default:true drops the zero value). Interactive Create still defaults new clients to enabled. Fixes #6478. * fix(clients): respect enable=false on node mirror; omit enable defaults true --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
@@ -80,15 +80,28 @@ type clientPayloadWithHwid struct {
|
|||||||
|
|
||||||
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
|
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
Client clientPayloadWithHwid `json:"client"`
|
Client json.RawMessage `json:"client"`
|
||||||
InboundIds []int `json:"inboundIds"`
|
InboundIds []int `json:"inboundIds"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.Client = raw.Client.Client
|
var withHwid clientPayloadWithHwid
|
||||||
|
if len(raw.Client) > 0 {
|
||||||
|
if err := json.Unmarshal(raw.Client, &withHwid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Client = withHwid.Client
|
||||||
p.InboundIds = raw.InboundIds
|
p.InboundIds = raw.InboundIds
|
||||||
p.LimitHwid = raw.Client.LimitHwid
|
p.LimitHwid = withHwid.LimitHwid
|
||||||
|
// Omit enable → true (legacy API); explicit false is preserved (#6478).
|
||||||
|
var keys map[string]json.RawMessage
|
||||||
|
if len(raw.Client) > 0 && json.Unmarshal(raw.Client, &keys) == nil {
|
||||||
|
if _, ok := keys["enable"]; !ok {
|
||||||
|
p.Client.Enable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1324,9 +1324,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
|||||||
if client.SubID == "" {
|
if client.SubID == "" {
|
||||||
client.SubID = uuid.NewString()
|
client.SubID = uuid.NewString()
|
||||||
}
|
}
|
||||||
if !client.Enable {
|
// Preserve enable (omit→true in UnmarshalJSON; explicit false kept) (#6478).
|
||||||
client.Enable = true
|
|
||||||
}
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
if client.CreatedAt == 0 {
|
if client.CreatedAt == 0 {
|
||||||
client.CreatedAt = now
|
client.CreatedAt = now
|
||||||
|
|||||||
@@ -154,9 +154,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
|||||||
if client.SubID == "" {
|
if client.SubID == "" {
|
||||||
client.SubID = uuid.NewString()
|
client.SubID = uuid.NewString()
|
||||||
}
|
}
|
||||||
if !client.Enable {
|
// Enable: omit defaults true via ClientCreatePayload.UnmarshalJSON; explicit false kept.
|
||||||
client.Enable = true
|
|
||||||
}
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
if client.CreatedAt == 0 {
|
if client.CreatedAt == 0 {
|
||||||
client.CreatedAt = now
|
client.CreatedAt = now
|
||||||
|
|||||||
@@ -617,6 +617,12 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||||||
push = false
|
push = false
|
||||||
}
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
|
// /clients/add on the node historically coerced enable=true; skip live
|
||||||
|
// push for disabled clients and leave dirty so reconcile converges.
|
||||||
|
if !client.Enable {
|
||||||
|
push = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
if push {
|
if push {
|
||||||
ctx, cancel := nodePushContext()
|
ctx, cancel := nodePushContext()
|
||||||
err1 := rt.AddClient(ctx, oldInbound, client)
|
err1 := rt.AddClient(ctx, oldInbound, client)
|
||||||
|
|||||||
@@ -169,11 +169,27 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(toCreate) > 0 {
|
if len(toCreate) > 0 {
|
||||||
|
// Capture enable before Create: gorm default:true drops explicit false (#6478).
|
||||||
|
// Restate disabled rows after CreateInBatches.
|
||||||
|
wantEnable := make([]bool, len(toCreate))
|
||||||
|
for i, rec := range toCreate {
|
||||||
|
wantEnable[i] = rec.Enable
|
||||||
|
}
|
||||||
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
|
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, rec := range toCreate {
|
disabledIDs := make([]int, 0)
|
||||||
|
for i, rec := range toCreate {
|
||||||
idByEmail[rec.Email] = rec.Id
|
idByEmail[rec.Email] = rec.Id
|
||||||
|
if !wantEnable[i] {
|
||||||
|
disabledIDs = append(disabledIDs, rec.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, batch := range chunkInts(disabledIDs, sqlInChunk) {
|
||||||
|
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", batch).
|
||||||
|
UpdateColumn("enable", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,7 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !client.Enable {
|
// Preserve exported enable so a disabled orphan stays disabled (#6478).
|
||||||
client.Enable = true
|
|
||||||
}
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
if client.CreatedAt == 0 {
|
if client.CreatedAt == 0 {
|
||||||
client.CreatedAt = now
|
client.CreatedAt = now
|
||||||
@@ -170,6 +168,13 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
|
|||||||
skip(email, err.Error())
|
skip(email, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// gorm default:true drops enable=false on Create — restate (#6478).
|
||||||
|
if !client.Enable {
|
||||||
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
||||||
|
UpdateColumn("enable", false).Error; err != nil {
|
||||||
|
return result, needRestart, err
|
||||||
|
}
|
||||||
|
}
|
||||||
result.Created++
|
result.Created++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExportImportPreservesDisabledEnable covers #6478: ExportAll keeps the
|
||||||
|
// real enable flag; ImportClients must not force enable=true.
|
||||||
|
func TestExportImportPreservesDisabledEnable(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
|
||||||
|
ib := mkInbound(t, 26001, model.VLESS, `{"clients":[]}`)
|
||||||
|
const email = "portable@disabled"
|
||||||
|
const subID = "sub-portable-disabled"
|
||||||
|
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||||
|
Client: model.Client{
|
||||||
|
Email: email, SubID: subID, Enable: true,
|
||||||
|
ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
},
|
||||||
|
InboundIds: []int{ib.Id},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := lookupClientRecord(t, email)
|
||||||
|
updated := rec.ToClient()
|
||||||
|
updated.Enable = false
|
||||||
|
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||||
|
t.Fatalf("Update disable: %v", err)
|
||||||
|
}
|
||||||
|
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||||
|
|
||||||
|
exported, err := svc.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(exported) != 1 {
|
||||||
|
t.Fatalf("ExportAll len=%d, want 1", len(exported))
|
||||||
|
}
|
||||||
|
if exported[0].Client.Enable {
|
||||||
|
t.Fatal("ExportAll should carry enable=false for a disabled client")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal export: %v", err)
|
||||||
|
}
|
||||||
|
var roundTrip []ClientCreatePayload
|
||||||
|
if err := json.Unmarshal(raw, &roundTrip); err != nil {
|
||||||
|
t.Fatalf("unmarshal export: %v", err)
|
||||||
|
}
|
||||||
|
if roundTrip[0].Client.Enable {
|
||||||
|
t.Fatal("JSON round-trip lost enable=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _, err := svc.ImportClients(inboundSvc, roundTrip)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportClients: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 1 || len(res.Skipped) != 0 {
|
||||||
|
t.Fatalf("ImportClients result=%+v", res)
|
||||||
|
}
|
||||||
|
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportClientsPreservesOrphanDisabledEnable(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
|
||||||
|
items := []ClientCreatePayload{{
|
||||||
|
Client: model.Client{
|
||||||
|
Email: "orphan@disabled", SubID: "sub-orphan-disabled", Enable: false,
|
||||||
|
ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
|
},
|
||||||
|
InboundIds: nil,
|
||||||
|
}}
|
||||||
|
res, _, err := svc.ImportClients(&InboundService{}, items)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportClients orphan: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 1 {
|
||||||
|
t.Fatalf("created=%d, want 1; skipped=%v", res.Created, res.Skipped)
|
||||||
|
}
|
||||||
|
if got := recordEnableOf(t, svc, "orphan@disabled"); got {
|
||||||
|
t.Fatal("orphan import forced enable=true; want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkCreatePreservesExplicitDisable(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
|
||||||
|
ib := mkInbound(t, 26002, model.VLESS, `{"clients":[]}`)
|
||||||
|
const email = "bulk@disabled"
|
||||||
|
res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
|
||||||
|
Client: model.Client{
|
||||||
|
Email: email, SubID: "sub-bulk-disabled", Enable: false,
|
||||||
|
ID: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||||
|
},
|
||||||
|
InboundIds: []int{ib.Id},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkCreate: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 1 {
|
||||||
|
t.Fatalf("BulkCreate result=%+v", res)
|
||||||
|
}
|
||||||
|
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCreatePayload_OmitEnableDefaultsTrue(t *testing.T) {
|
||||||
|
raw := []byte(`{"client":{"email":"omit@x","id":"dddddddd-dddd-dddd-dddd-dddddddddddd","subId":"sub-omit"},"inboundIds":[1]}`)
|
||||||
|
var p ClientCreatePayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !p.Client.Enable {
|
||||||
|
t.Fatal("omitted enable must default to true")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawFalse := []byte(`{"client":{"email":"off@x","id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","subId":"sub-off","enable":false},"inboundIds":[1]}`)
|
||||||
|
var pFalse ClientCreatePayload
|
||||||
|
if err := json.Unmarshal(rawFalse, &pFalse); err != nil {
|
||||||
|
t.Fatalf("unmarshal false: %v", err)
|
||||||
|
}
|
||||||
|
if pFalse.Client.Enable {
|
||||||
|
t.Fatal("explicit enable:false must stay false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkCreate_DisabledOnNodeSkipsAddClient(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
nodeID, fake := setupNodeRuntime(t)
|
||||||
|
ib := nodeInbound(t, nodeID, 26003, nil)
|
||||||
|
svc := &ClientService{}
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
|
||||||
|
const email = "node@disabled"
|
||||||
|
res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
|
||||||
|
Client: model.Client{
|
||||||
|
Email: email, SubID: "sub-node-disabled", Enable: false,
|
||||||
|
ID: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||||
|
},
|
||||||
|
InboundIds: []int{ib.Id},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BulkCreate: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 1 {
|
||||||
|
t.Fatalf("BulkCreate result=%+v", res)
|
||||||
|
}
|
||||||
|
if got := fake.addClient.Load(); got != 0 {
|
||||||
|
t.Fatalf("AddClient RPCs = %d, want 0 for enable=false", got)
|
||||||
|
}
|
||||||
|
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||||
|
if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
|
||||||
|
t.Fatalf("NodeSyncState: %v", err)
|
||||||
|
} else if !dirty {
|
||||||
|
t.Fatal("disabled node create must leave node dirty for reconcile")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user