From 1110caaa65ec14a83a9df7aef390287e676b1a09 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 28 Sep 2026 02:23:32 +0200 Subject: [PATCH] fix(inbounds): keep stored client lifecycle and counters on inbound save Invariant: saving an inbound's configuration never changes a client's enable, expiry, quota or renewal state, nor the inbound's own traffic counters. The inbound modal posts back the whole settings.clients list it loaded when it opened, and UpdateInbound stored it, synced it into the client records and wrote it into client_traffics. Any client renewed, depleted or reset while the modal was open was reverted - a renewed client came back disabled with its old expiry. The row itself was read before the serialized tx and saved whole, so traffic the poll added in between was rolled back and a depleted inbound could be re-enabled. UpdateInbound now re-reads the row inside the writer and, for clients the inbound already holds, keeps enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax from it; those change through the client endpoints. A master's node-sync push stays authoritative. Existing clients' lifecycle fields are no longer editable through the inbound JSON editor or /inbounds/update, which the API docs now state. --- .../docs/en/reference/api/inbounds.mdx | 20 ++-- docs/public/openapi.json | 2 +- frontend/public/openapi.json | 2 +- frontend/src/pages/api-docs/endpoints.ts | 2 +- internal/web/service/inbound.go | 10 ++ .../web/service/inbound_settings_commit.go | 45 +++++++++ .../service/inbound_update_stale_form_test.go | 94 +++++++++++++++++++ 7 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 internal/web/service/inbound_update_stale_form_test.go diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx index 9f3ff6b8d..1b80a29b6 100644 --- a/docs/content/docs/en/reference/api/inbounds.mdx +++ b/docs/content/docs/en/reference/api/inbounds.mdx @@ -60,10 +60,12 @@ _openapi: at most once. url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once' - depth: 2 - title: Replace an inbound’s configuration. Body shape mirrors /add. Heavy on - inbounds with thousands of clients — prefer /setEnable for enable-only - flips. - url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips' + title: Replace an inbound’s configuration. Body shape mirrors /add. Clients the + inbound already holds keep their stored enable, expiryTime, totalGB, + reset, resetDay, resetWeekday and resetMax — change those through the + /panel/api/clients endpoints. Heavy on inbounds with thousands of + clients — prefer /setEnable for enable-only flips. + url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-clients-the-inbound-already-holds-keep-their-stored-enable-expirytime-totalgb-reset-resetday-resetweekday-and-resetmax--change-those-through-the-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips' - depth: 2 title: Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds. @@ -151,10 +153,12 @@ _openapi: failures are reported per id and the rest still proceed. Restarts xray at most once. id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once - - content: Replace an inbound’s configuration. Body shape mirrors /add. Heavy on - inbounds with thousands of clients — prefer /setEnable for enable-only - flips. - id: replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips + - content: Replace an inbound’s configuration. Body shape mirrors /add. Clients + the inbound already holds keep their stored enable, expiryTime, + totalGB, reset, resetDay, resetWeekday and resetMax — change those + through the /panel/api/clients endpoints. Heavy on inbounds with + thousands of clients — prefer /setEnable for enable-only flips. + id: replace-an-inbounds-configuration-body-shape-mirrors-add-clients-the-inbound-already-holds-keep-their-stored-enable-expirytime-totalgb-reset-resetday-resetweekday-and-resetmax--change-those-through-the-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips - content: Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds. id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 4919f5664..a27056eab 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -5603,7 +5603,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", + "summary": "Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", "operationId": "post_panel_api_inbounds_update_id", "parameters": [ { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 4919f5664..a27056eab 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5603,7 +5603,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", + "summary": "Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", "operationId": "post_panel_api_inbounds_update_id", "parameters": [ { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 3fca5dc01..b0666a5c6 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -325,7 +325,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/inbounds/update/:id', summary: - 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.', + 'Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.', params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }], body: inboundBody, }, diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 7b7e80c01..a5b576ad7 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1770,6 +1770,16 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, var postCommitApply func() txErr := runSerializedTx(func(tx *gorm.DB) error { + // Re-read inside the writer: a traffic tick since the read above moved the + // counters and may have renewed or disabled clients. + stored := &model.Inbound{} + if err := tx.First(stored, inbound.Id).Error; err != nil { + return err + } + oldInbound = stored + if !s.FromNodeSync { + inbound.Settings = keepStoredClientLifecycle(inbound.Settings, stored.Settings) + } conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id) if cErr != nil { return cErr diff --git a/internal/web/service/inbound_settings_commit.go b/internal/web/service/inbound_settings_commit.go index 9ab1badd4..163c57c86 100644 --- a/internal/web/service/inbound_settings_commit.go +++ b/internal/web/service/inbound_settings_commit.go @@ -139,3 +139,48 @@ func mergeClientLists(base, ours, current []any) []any { } return out } + +// Client limits and state the inbound form never owns: the client endpoints and +// traffic jobs change them, so a form opened earlier must not write them back. +var storedClientLifecycleKeys = []string{"enable", "expiryTime", "totalGB", "reset", "resetDay", "resetWeekday", "resetMax"} + +// keepStoredClientLifecycle copies those keys from the stored settings onto every +// payload client the inbound already holds; new clients keep what they carry. +func keepStoredClientLifecycle(payload, stored string) string { + var payloadM, storedM map[string]any + if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil { + return payload + } + storedClients, _ := storedM["clients"].([]any) + storedBy := indexClientsByEmail(storedClients) + payloadClients, _ := payloadM["clients"].([]any) + changed := false + for _, entry := range payloadClients { + p, email := clientEntryEmail(entry) + s, known := storedBy[email] + if !known { + continue + } + for _, key := range storedClientLifecycleKeys { + sv, inStored := s[key] + pv, inPayload := p[key] + if inStored == inPayload && reflect.DeepEqual(sv, pv) { + continue + } + changed = true + if inStored { + p[key] = sv + } else { + delete(p, key) + } + } + } + if !changed { + return payload + } + b, err := json.MarshalIndent(payloadM, "", " ") + if err != nil { + return payload + } + return string(b) +} diff --git a/internal/web/service/inbound_update_stale_form_test.go b/internal/web/service/inbound_update_stale_form_test.go new file mode 100644 index 000000000..4cb78bb00 --- /dev/null +++ b/internal/web/service/inbound_update_stale_form_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "gorm.io/gorm" +) + +func readClientTraffic(t *testing.T, email string) xray.ClientTraffic { + t.Helper() + var row xray.ClientTraffic + if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil { + t.Fatalf("read client_traffics %s: %v", email, err) + } + return row +} + +// The inbound modal posts the clients it loaded on open; a renewal committed +// while it was open must survive the save in settings, record and stats. +func TestInboundFormSaveKeepsClientRenewedWhileOpen(t *testing.T) { + setupBulkDB(t) + ib := seedRenewableNeighbour(t, 23201, nil) + form := *ib + + if err := database.GetDB().Transaction(autoRenewTick); err != nil { + t.Fatalf("autoRenew: %v", err) + } + form.Remark = "edited" + if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + + requireNeighbourRenewed(t, ib.Id) + now := time.Now().UnixMilli() + if row := readClientTraffic(t, "y@stale"); !row.Enable || row.ExpiryTime <= now { + t.Fatalf("client_traffics rolled back: enable=%v expiryTime=%d", row.Enable, row.ExpiryTime) + } + if rec := lookupClientRecord(t, "y@stale"); !rec.Enable || rec.ExpiryTime <= now { + t.Fatalf("client record rolled back: enable=%v expiryTime=%d", rec.Enable, rec.ExpiryTime) + } +} + +// On a node the master's push is authoritative, lifecycle fields included. +func TestInboundUpdateFromMasterAppliesClientLifecycle(t *testing.T) { + setupBulkDB(t) + ib := seedRenewableNeighbour(t, 23202, nil) + clients, err := (&InboundService{}).GetClients(ib) + if err != nil { + t.Fatalf("GetClients: %v", err) + } + for i := range clients { + if clients[i].Email == "x@stale" { + clients[i].Enable = false + } + } + push := *ib + push.Settings = clientsSettings(t, clients) + if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&push); err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + if x, _ := settingsClient(t, ib.Id, "x@stale"); x.Enable { + t.Fatal("master push disabling x@stale was ignored in settings") + } + if readClientTraffic(t, "x@stale").Enable { + t.Fatal("master push disabling x@stale was ignored in client_traffics") + } +} + +// Traffic the poll adds after UpdateInbound read the row must not be written +// back over by the edit. +func TestInboundUpdateKeepsTrafficAddedMidEdit(t *testing.T) { + setupBulkDB(t) + ib := seedRenewableNeighbour(t, 23203, nil) + form := *ib + form.Remark = "edited" + commitTickBetweenReadAndWrite(t, func(tx *gorm.DB) error { + return (&InboundService{}).addInboundTraffic(tx, []*xray.Traffic{{IsInbound: true, Tag: ib.Tag, Up: 100, Down: 50}}) + }, func() { + if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil { + t.Errorf("UpdateInbound: %v", err) + } + }) + saved, err := (&InboundService{}).GetInbound(ib.Id) + if err != nil { + t.Fatalf("GetInbound: %v", err) + } + if saved.Up != 100 || saved.Down != 50 || saved.Remark != "edited" { + t.Fatalf("inbound after edit: up=%d down=%d remark=%q, want 100/50/edited", saved.Up, saved.Down, saved.Remark) + } +}