Forráskód Böngészése

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 <[email protected]>
mrchatam 4 órája
szülő
commit
5fbd2b490c

+ 17 - 4
internal/web/service/client.go

@@ -80,15 +80,28 @@ type clientPayloadWithHwid struct {
 
 func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
 	var raw struct {
-		Client     clientPayloadWithHwid `json:"client"`
-		InboundIds []int                 `json:"inboundIds"`
+		Client     json.RawMessage `json:"client"`
+		InboundIds []int           `json:"inboundIds"`
 	}
 	if err := json.Unmarshal(data, &raw); err != nil {
 		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.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
 }
 

+ 1 - 3
internal/web/service/client_bulk.go

@@ -1324,9 +1324,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		if client.SubID == "" {
 			client.SubID = uuid.NewString()
 		}
-		if !client.Enable {
-			client.Enable = true
-		}
+		// Preserve enable (omit→true in UnmarshalJSON; explicit false kept) (#6478).
 		now := time.Now().UnixMilli()
 		if client.CreatedAt == 0 {
 			client.CreatedAt = now

+ 1 - 3
internal/web/service/client_crud.go

@@ -154,9 +154,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 	if client.SubID == "" {
 		client.SubID = uuid.NewString()
 	}
-	if !client.Enable {
-		client.Enable = true
-	}
+	// Enable: omit defaults true via ClientCreatePayload.UnmarshalJSON; explicit false kept.
 	now := time.Now().UnixMilli()
 	if client.CreatedAt == 0 {
 		client.CreatedAt = now

+ 6 - 0
internal/web/service/client_inbound_apply.go

@@ -617,6 +617,12 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			push = false
 		}
 		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 {
 				ctx, cancel := nodePushContext()
 				err1 := rt.AddClient(ctx, oldInbound, client)

+ 17 - 1
internal/web/service/client_link.go

@@ -169,11 +169,27 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 	}
 
 	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 {
 			return err
 		}
-		for _, rec := range toCreate {
+		disabledIDs := make([]int, 0)
+		for i, rec := range toCreate {
 			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
+			}
 		}
 	}
 

+ 8 - 3
internal/web/service/client_portable.go

@@ -155,9 +155,7 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 				continue
 			}
 		}
-		if !client.Enable {
-			client.Enable = true
-		}
+		// Preserve exported enable so a disabled orphan stays disabled (#6478).
 		now := time.Now().UnixMilli()
 		if client.CreatedAt == 0 {
 			client.CreatedAt = now
@@ -170,6 +168,13 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 			skip(email, err.Error())
 			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++
 	}
 

+ 171 - 0
internal/web/service/client_portable_test.go

@@ -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")
+	}
+}