From 15d82a5e47c68700a1b7a3e3492f245f139e033a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 27 Sep 2026 18:28:07 +0200 Subject: [PATCH] fix(inbounds): accept v2.x client fields on inbound import Panels up to v2.x stored a client's tgId as a string, "" when unset. The startup migration heals copies already in the database, but an exported inbound imported into a current panel goes straight to AddInbound, whose typed client parse failed with "cannot unmarshal string into Go struct field .0.tgId of type int64", so every such import was refused. AddInbound now runs the legacy normalizer the clients-table seeder already used (moved from database to model so both share it) over the incoming settings before parsing them. String numbers become integers and empty ones are dropped, and the settings are stored in that shape. The same applies to /inbounds/add callers still sending the old types. The seeder change is a pure move; the PostgreSQL lane was not run (no Docker on this host) and is left to CI. Closes #6663 --- internal/database/db.go | 28 +----------- .../database/model/client_legacy_fields.go | 27 ++++++++++++ internal/web/service/inbound.go | 1 + .../inbound_import_legacy_fields_test.go | 44 +++++++++++++++++++ .../web/service/inbound_settings_clients.go | 25 +++++++++++ 5 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 internal/database/model/client_legacy_fields.go create mode 100644 internal/web/service/inbound_import_legacy_fields_test.go diff --git a/internal/database/db.go b/internal/database/db.go index 2e5c99f83..16608c55b 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -2521,32 +2521,6 @@ func isAllowOnlyFinalRules(v any) bool { return true } -func normalizeClientJSONFields(obj map[string]any) { - normalizeInt := func(key string) { - raw, exists := obj[key] - if !exists { - return - } - s, ok := raw.(string) - if !ok { - return - } - trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "") - if trimmed == "" { - delete(obj, key) - return - } - if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { - obj[key] = n - } else { - delete(obj, key) - } - } - for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} { - normalizeInt(k) - } -} - func seedClientsFromInboundJSON() error { var inbounds []model.Inbound if err := db.Find(&inbounds).Error; err != nil { @@ -2583,7 +2557,7 @@ func seedClientsFromInboundJSON() error { if !ok { continue } - normalizeClientJSONFields(obj) + model.NormalizeLegacyClientFields(obj) blob, err := json.Marshal(obj) if err != nil { continue diff --git a/internal/database/model/client_legacy_fields.go b/internal/database/model/client_legacy_fields.go new file mode 100644 index 000000000..a4bcfdc88 --- /dev/null +++ b/internal/database/model/client_legacy_fields.go @@ -0,0 +1,27 @@ +package model + +import ( + "strconv" + "strings" +) + +var legacyClientIntFields = []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} + +// NormalizeLegacyClientFields turns the string numbers older panels stored on a +// client object into integers; an empty or unparseable value is dropped. +func NormalizeLegacyClientFields(obj map[string]any) (changed bool) { + for _, key := range legacyClientIntFields { + s, ok := obj[key].(string) + if !ok { + continue + } + changed = true + trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "") + if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + obj[key] = n + } else { + delete(obj, key) + } + } + return changed +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 4ad687f4b..7b7e80c01 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1129,6 +1129,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo } inbound.Tag = tag + normalizeLegacyClientSettings(inbound) clients, err := s.GetClients(inbound) if err != nil { return inbound, false, err diff --git a/internal/web/service/inbound_import_legacy_fields_test.go b/internal/web/service/inbound_import_legacy_fields_test.go new file mode 100644 index 000000000..28da8407b --- /dev/null +++ b/internal/web/service/inbound_import_legacy_fields_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Exports from v2.x store tgId as a string ("" when unset); the stored copies +// are healed by a startup migration, but an imported export never passes it. +func TestAddInboundAcceptsLegacyStringClientFields(t *testing.T) { + setupConflictDB(t) + settings := `{"clients":[` + + `{"id":"11111111-1111-1111-1111-111111111111","email":"legacy-a","tgId":"","subId":"s-a","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0},` + + `{"id":"22222222-2222-2222-2222-222222222222","email":"legacy-b","tgId":"123456","subId":"s-b","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0}` + + `],"decryption":"none","fallbacks":[]}` + in := makeImportInbound("in-9201-tcp", 9201, settings, nil) + + saved, _, err := (&InboundService{}).AddInbound(in) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + + var rec model.ClientRecord + if err := database.GetDB().Where("email = ?", "legacy-b").First(&rec).Error; err != nil { + t.Fatalf("read client: %v", err) + } + if rec.TgID != 123456 { + t.Fatalf("client tgId = %d, want 123456 parsed from the string", rec.TgID) + } + var stored struct { + Clients []map[string]any `json:"clients"` + } + if err := json.Unmarshal([]byte(saved.Settings), &stored); err != nil { + t.Fatalf("stored settings: %v", err) + } + for _, c := range stored.Clients { + if _, isString := c["tgId"].(string); isString { + t.Fatalf("stored settings keep a string tgId for %v: %v", c["email"], c["tgId"]) + } + } +} diff --git a/internal/web/service/inbound_settings_clients.go b/internal/web/service/inbound_settings_clients.go index 1bdf4a214..7522e3cb4 100644 --- a/internal/web/service/inbound_settings_clients.go +++ b/internal/web/service/inbound_settings_clients.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "encoding/json" "strings" @@ -48,3 +49,27 @@ func settingsEntriesToClients(entries []any) ([]model.Client, error) { } return clients, nil } + +// normalizeLegacyClientSettings rewrites settings whose clients still carry the +// string numbers of a v2.x export, so parsing and storage see a single shape. +func normalizeLegacyClientSettings(inbound *model.Inbound) { + dec := json.NewDecoder(bytes.NewReader([]byte(inbound.Settings))) + dec.UseNumber() + var settings map[string]any + if err := dec.Decode(&settings); err != nil { + return + } + clients, _ := settings["clients"].([]any) + changed := false + for _, raw := range clients { + if obj, ok := raw.(map[string]any); ok && model.NormalizeLegacyClientFields(obj) { + changed = true + } + } + if !changed { + return + } + if out, err := json.MarshalIndent(settings, "", " "); err == nil { + inbound.Settings = string(out) + } +}