diff --git a/internal/web/service/client.go b/internal/web/service/client.go index 80ec4ad55..568b4113a 100644 --- a/internal/web/service/client.go +++ b/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 } diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 8ab319bbb..70b848133 100644 --- a/internal/web/service/client_bulk.go +++ b/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 diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 2c3a3034a..0a5eaa40d 100644 --- a/internal/web/service/client_crud.go +++ b/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 diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index df9bb4784..34942e140 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/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) diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index c3e24aea1..2179bf4f8 100644 --- a/internal/web/service/client_link.go +++ b/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 + } } } diff --git a/internal/web/service/client_portable.go b/internal/web/service/client_portable.go index 66abb5056..2924d88ed 100644 --- a/internal/web/service/client_portable.go +++ b/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++ } diff --git a/internal/web/service/client_portable_test.go b/internal/web/service/client_portable_test.go new file mode 100644 index 000000000..248e6134a --- /dev/null +++ b/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") + } +}