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