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
This commit is contained in:
MHSanaei
2026-09-27 18:28:07 +02:00
parent 092cbd55e4
commit 15d82a5e47
5 changed files with 98 additions and 27 deletions
+1 -27
View File
@@ -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
@@ -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
}
+1
View File
@@ -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
@@ -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"])
}
}
}
@@ -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)
}
}