From b42a4d93fc47827670eff5d28dd91acb8191ad6a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 28 May 2026 15:11:53 +0200 Subject: [PATCH] fix(inbounds): heal legacy client data and TLS cert form hydration - Detach preserves client traffic stats. DelInboundClient, DelInboundClientByEmail, and bulkDelInboundClients now take a keepTraffic flag; Detach passes true, delete-paths keep prior behavior. Runtime user removal still runs so xray drops the session. - Two startup seeders normalize legacy inbound settings JSON: clients:null -> [] and any non-numeric tgId -> 0 (string, bool, NaN, Inf, non-integer floats). Each records itself once in history_of_seeders. - MigrationRequirements no longer rewrites empty clients arrays back to null: newClients is initialized as a non-nil slice and incoming clients:null is coerced before the type assertion. - TLS cert form: rawInboundToFormValues synthesizes a useFile discriminator per cert from whichever side carries data, so the edit modal can show file-mode paths again. formValuesToWirePayload strips useFile so saved JSON stays in wire shape. --- database/db.go | 106 +++++++++++++++++- frontend/src/lib/xray/inbound-form-adapter.ts | 36 ++++-- web/service/client.go | 37 +++--- web/service/inbound.go | 5 +- 4 files changed, 156 insertions(+), 28 deletions(-) diff --git a/database/db.go b/database/db.go index 6685db1d..709a8941 100644 --- a/database/db.go +++ b/database/db.go @@ -8,6 +8,7 @@ import ( "errors" "io" "log" + "math" "os" "path" "slices" @@ -143,7 +144,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -196,9 +197,112 @@ func runSeeders(isUsersEmpty bool) error { return err } } + + if !slices.Contains(seedersHistory, "InboundClientsArrayFix") { + if err := normalizeInboundClientsArray(); err != nil { + return err + } + } + + if !slices.Contains(seedersHistory, "InboundClientTgIdFix") { + if err := normalizeInboundClientTgId(); err != nil { + return err + } + } return nil } +func normalizeInboundClientTgId() error { + var inbounds []model.Inbound + if err := db.Find(&inbounds).Error; err != nil { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + for _, inbound := range inbounds { + if strings.TrimSpace(inbound.Settings) == "" { + continue + } + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + log.Printf("InboundClientTgIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err) + continue + } + clients, ok := settings["clients"].([]any) + if !ok { + continue + } + mutated := false + for i, raw := range clients { + obj, ok := raw.(map[string]any) + if !ok { + continue + } + tgRaw, present := obj["tgId"] + if !present { + continue + } + v, isFloat := tgRaw.(float64) + if isFloat && !math.IsNaN(v) && !math.IsInf(v, 0) && v == math.Trunc(v) { + continue + } + obj["tgId"] = int64(0) + clients[i] = obj + mutated = true + } + if !mutated { + continue + } + settings["clients"] = clients + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + log.Printf("InboundClientTgIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err) + continue + } + if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id). + Update("settings", string(newSettings)).Error; err != nil { + return err + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientTgIdFix"}).Error + }) +} + +func normalizeInboundClientsArray() error { + var inbounds []model.Inbound + if err := db.Find(&inbounds).Error; err != nil { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + for _, inbound := range inbounds { + if strings.TrimSpace(inbound.Settings) == "" { + continue + } + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + log.Printf("InboundClientsArrayFix: skip inbound %d (invalid settings json): %v", inbound.Id, err) + continue + } + raw, exists := settings["clients"] + if !exists || raw != nil { + continue + } + settings["clients"] = []any{} + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + log.Printf("InboundClientsArrayFix: skip inbound %d (marshal failed): %v", inbound.Id, err) + continue + } + if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id). + Update("settings", string(newSettings)).Error; err != nil { + return err + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientsArrayFix"}).Error + }) +} + // normalizeClientJSONFields coerces loosely-typed numeric fields in a raw // settings.clients entry so json.Unmarshal into model.Client doesn't fail // when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 62f3966b..3488111f 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -112,10 +112,26 @@ function healStreamNetworkKey(stream: Record): void { } } -// Map a raw DB row (settings/streamSettings/sniffing as string OR object) -// into the typed InboundFormValues. Does NOT validate against the schema — -// callers that want a hard guarantee should follow up with -// InboundFormSchema.safeParse(...). +function tlsCerts(stream: Record): Record[] { + const tls = stream.tlsSettings as { certificates?: unknown } | undefined; + return Array.isArray(tls?.certificates) ? tls.certificates as Record[] : []; +} + +function synthesizeTlsCertUseFile(stream: Record): void { + for (const c of tlsCerts(stream)) { + if (typeof c.useFile === 'boolean') continue; + const hasFile = !!c.certificateFile || !!c.keyFile; + const hasInline = + (Array.isArray(c.certificate) && c.certificate.length > 0) || + (Array.isArray(c.key) && c.key.length > 0); + c.useFile = hasFile || !hasInline; + } +} + +function stripTlsCertUseFile(stream: Record): void { + for (const c of tlsCerts(stream)) delete c.useFile; +} + export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { const protocol = (row.protocol || 'vless') as InboundSettings['protocol']; const settings = coerceJsonObject(row.settings) as InboundSettings['settings']; @@ -125,6 +141,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { : undefined; if (streamSettings) { healStreamNetworkKey(streamSettings as unknown as Record); + synthesizeTlsCertUseFile(streamSettings as unknown as Record); } const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; @@ -181,12 +198,12 @@ export function pruneEmpty(value: unknown): unknown { // gives us the canonical projection. function clientSchemaForProtocol(protocol: string): z.ZodType | null { switch (protocol) { - case 'vless': return VlessClientSchema; - case 'vmess': return VmessClientSchema; - case 'trojan': return TrojanClientSchema; + case 'vless': return VlessClientSchema; + case 'vmess': return VmessClientSchema; + case 'trojan': return TrojanClientSchema; case 'shadowsocks': return ShadowsocksClientSchema; - case 'hysteria': return HysteriaClientSchema; - default: return null; + case 'hysteria': return HysteriaClientSchema; + default: return null; } } @@ -265,6 +282,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP const streamPruned = values.streamSettings ? ((pruneEmpty(values.streamSettings) ?? {}) as Record) : undefined; + if (streamPruned) stripTlsCertUseFile(streamPruned); dropLegacyOptionalEmpties(settingsPruned, streamPruned); const payload: WireInboundPayload = { up: values.up, diff --git a/web/service/client.go b/web/service/client.go index a15d0e1b..7a55c622 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -687,7 +687,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b if key == "" { continue } - nr, delErr := s.DelInboundClient(inboundSvc, ibId, key) + nr, delErr := s.DelInboundClient(inboundSvc, ibId, key, false) if delErr != nil { return needRestart, delErr } @@ -984,7 +984,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, } needRestart := false for _, ibId := range inboundIds { - nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email) + nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false) if delErr != nil { return needRestart, delErr } @@ -2393,7 +2393,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string, needRestart := false for inboundId, ibEmails := range emailsByInbound { - ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail) + ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, false) if ibResult.needRestart { needRestart = true } @@ -2453,6 +2453,7 @@ func (s *ClientService) bulkDelInboundClients( inboundId int, emails []string, records map[string]*model.ClientRecord, + keepTraffic bool, ) bulkInboundDeleteResult { res := bulkInboundDeleteResult{perEmailSkipped: map[string]string{}} @@ -2574,7 +2575,7 @@ func (s *ClientService) bulkDelInboundClients( delete(foundEmails, email) continue } - if shared { + if shared || keepTraffic { continue } if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil { @@ -2807,7 +2808,7 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds [] if key == "" { continue } - nr, delErr := s.DelInboundClient(inboundSvc, ibId, key) + nr, delErr := s.DelInboundClient(inboundSvc, ibId, key, true) if delErr != nil { return needRestart, delErr } @@ -3282,7 +3283,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo return needRestart, nil } -func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) { +func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string, keepTraffic bool) (bool, error) { defer lockInbound(inboundId).Unlock() oldInbound, err := inboundSvc.GetInbound(inboundId) @@ -3345,7 +3346,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i return false, err } - if !emailShared { + if !emailShared && !keepTraffic { err = inboundSvc.DelClientIPs(db, email) if err != nil { logger.Error("Error in delete client IPs") @@ -3362,7 +3363,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i return false, err } notDepleted := len(enables) > 0 && enables[0] - if !emailShared { + if !emailShared && !keepTraffic { err = inboundSvc.DelClientStat(db, email) if err != nil { logger.Error("Delete stats Data Error") @@ -3409,7 +3410,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i return needRestart, nil } -func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) { +func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string, keepTraffic bool) (bool, error) { defer lockInbound(inboundId).Unlock() oldInbound, err := inboundSvc.GetInbound(inboundId) @@ -3466,7 +3467,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo return false, err } - if !emailShared { + if !emailShared && !keepTraffic { if err := inboundSvc.DelClientIPs(db, email); err != nil { logger.Error("Error in delete client IPs") return false, err @@ -3476,15 +3477,17 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo needRestart := false if len(email) > 0 && !emailShared { - traffic, err := inboundSvc.GetClientTrafficByEmail(email) - if err != nil { - return false, err - } - if traffic != nil { - if err := inboundSvc.DelClientStat(db, email); err != nil { - logger.Error("Delete stats Data Error") + if !keepTraffic { + traffic, err := inboundSvc.GetClientTrafficByEmail(email) + if err != nil { return false, err } + if traffic != nil { + if err := inboundSvc.DelClientStat(db, email); err != nil { + logger.Error("Delete stats Data Error") + return false, err + } + } } if needApiDel { diff --git a/web/service/inbound.go b/web/service/inbound.go index 519ff97b..dba0504e 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -2988,10 +2988,13 @@ func (s *InboundService) MigrationRequirements() { for inbound_index := range inbounds { settings := map[string]any{} json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings) + if raw, exists := settings["clients"]; exists && raw == nil { + settings["clients"] = []any{} + } clients, ok := settings["clients"].([]any) if ok { // Fix Client configuration problems - var newClients []any + newClients := make([]any, 0, len(clients)) hasVisionFlow := false for client_index := range clients { c := clients[client_index].(map[string]any)