mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
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.
This commit is contained in:
parent
8046d1519d
commit
b42a4d93fc
4 changed files with 156 additions and 28 deletions
106
database/db.go
106
database/db.go
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -143,7 +144,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if empty && isUsersEmpty {
|
if empty && isUsersEmpty {
|
||||||
seeders := []string{"UserPasswordHash", "ClientsTable"}
|
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix"}
|
||||||
for _, name := range seeders {
|
for _, name := range seeders {
|
||||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -196,9 +197,112 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
return err
|
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
|
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
|
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
|
||||||
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
||||||
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,26 @@ function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map a raw DB row (settings/streamSettings/sniffing as string OR object)
|
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
|
||||||
// into the typed InboundFormValues. Does NOT validate against the schema —
|
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
|
||||||
// callers that want a hard guarantee should follow up with
|
return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
|
||||||
// InboundFormSchema.safeParse(...).
|
}
|
||||||
|
|
||||||
|
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): 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<string, unknown>): void {
|
||||||
|
for (const c of tlsCerts(stream)) delete c.useFile;
|
||||||
|
}
|
||||||
|
|
||||||
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||||
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
||||||
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
||||||
|
|
@ -125,6 +141,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||||
: undefined;
|
: undefined;
|
||||||
if (streamSettings) {
|
if (streamSettings) {
|
||||||
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||||
|
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||||
|
|
||||||
|
|
@ -181,12 +198,12 @@ export function pruneEmpty(value: unknown): unknown {
|
||||||
// gives us the canonical projection.
|
// gives us the canonical projection.
|
||||||
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case 'vless': return VlessClientSchema;
|
case 'vless': return VlessClientSchema;
|
||||||
case 'vmess': return VmessClientSchema;
|
case 'vmess': return VmessClientSchema;
|
||||||
case 'trojan': return TrojanClientSchema;
|
case 'trojan': return TrojanClientSchema;
|
||||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||||
case 'hysteria': return HysteriaClientSchema;
|
case 'hysteria': return HysteriaClientSchema;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +282,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||||
const streamPruned = values.streamSettings
|
const streamPruned = values.streamSettings
|
||||||
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (streamPruned) stripTlsCertUseFile(streamPruned);
|
||||||
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
||||||
const payload: WireInboundPayload = {
|
const payload: WireInboundPayload = {
|
||||||
up: values.up,
|
up: values.up,
|
||||||
|
|
|
||||||
|
|
@ -687,7 +687,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
|
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key, false)
|
||||||
if delErr != nil {
|
if delErr != nil {
|
||||||
return needRestart, delErr
|
return needRestart, delErr
|
||||||
}
|
}
|
||||||
|
|
@ -984,7 +984,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
||||||
}
|
}
|
||||||
needRestart := false
|
needRestart := false
|
||||||
for _, ibId := range inboundIds {
|
for _, ibId := range inboundIds {
|
||||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email)
|
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false)
|
||||||
if delErr != nil {
|
if delErr != nil {
|
||||||
return needRestart, delErr
|
return needRestart, delErr
|
||||||
}
|
}
|
||||||
|
|
@ -2393,7 +2393,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
for inboundId, ibEmails := range emailsByInbound {
|
for inboundId, ibEmails := range emailsByInbound {
|
||||||
ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail)
|
ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, false)
|
||||||
if ibResult.needRestart {
|
if ibResult.needRestart {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
|
|
@ -2453,6 +2453,7 @@ func (s *ClientService) bulkDelInboundClients(
|
||||||
inboundId int,
|
inboundId int,
|
||||||
emails []string,
|
emails []string,
|
||||||
records map[string]*model.ClientRecord,
|
records map[string]*model.ClientRecord,
|
||||||
|
keepTraffic bool,
|
||||||
) bulkInboundDeleteResult {
|
) bulkInboundDeleteResult {
|
||||||
res := bulkInboundDeleteResult{perEmailSkipped: map[string]string{}}
|
res := bulkInboundDeleteResult{perEmailSkipped: map[string]string{}}
|
||||||
|
|
||||||
|
|
@ -2574,7 +2575,7 @@ func (s *ClientService) bulkDelInboundClients(
|
||||||
delete(foundEmails, email)
|
delete(foundEmails, email)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if shared {
|
if shared || keepTraffic {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
|
if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
|
||||||
|
|
@ -2807,7 +2808,7 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
|
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key, true)
|
||||||
if delErr != nil {
|
if delErr != nil {
|
||||||
return needRestart, delErr
|
return needRestart, delErr
|
||||||
}
|
}
|
||||||
|
|
@ -3282,7 +3283,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||||
return needRestart, nil
|
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()
|
defer lockInbound(inboundId).Unlock()
|
||||||
|
|
||||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||||
|
|
@ -3345,7 +3346,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !emailShared {
|
if !emailShared && !keepTraffic {
|
||||||
err = inboundSvc.DelClientIPs(db, email)
|
err = inboundSvc.DelClientIPs(db, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error in delete client IPs")
|
logger.Error("Error in delete client IPs")
|
||||||
|
|
@ -3362,7 +3363,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
notDepleted := len(enables) > 0 && enables[0]
|
notDepleted := len(enables) > 0 && enables[0]
|
||||||
if !emailShared {
|
if !emailShared && !keepTraffic {
|
||||||
err = inboundSvc.DelClientStat(db, email)
|
err = inboundSvc.DelClientStat(db, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Delete stats Data Error")
|
logger.Error("Delete stats Data Error")
|
||||||
|
|
@ -3409,7 +3410,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
return needRestart, nil
|
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()
|
defer lockInbound(inboundId).Unlock()
|
||||||
|
|
||||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||||
|
|
@ -3466,7 +3467,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !emailShared {
|
if !emailShared && !keepTraffic {
|
||||||
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
||||||
logger.Error("Error in delete client IPs")
|
logger.Error("Error in delete client IPs")
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -3476,15 +3477,17 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
|
||||||
if len(email) > 0 && !emailShared {
|
if len(email) > 0 && !emailShared {
|
||||||
traffic, err := inboundSvc.GetClientTrafficByEmail(email)
|
if !keepTraffic {
|
||||||
if err != nil {
|
traffic, err := inboundSvc.GetClientTrafficByEmail(email)
|
||||||
return false, err
|
if err != nil {
|
||||||
}
|
|
||||||
if traffic != nil {
|
|
||||||
if err := inboundSvc.DelClientStat(db, email); err != nil {
|
|
||||||
logger.Error("Delete stats Data Error")
|
|
||||||
return false, err
|
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 {
|
if needApiDel {
|
||||||
|
|
|
||||||
|
|
@ -2988,10 +2988,13 @@ func (s *InboundService) MigrationRequirements() {
|
||||||
for inbound_index := range inbounds {
|
for inbound_index := range inbounds {
|
||||||
settings := map[string]any{}
|
settings := map[string]any{}
|
||||||
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
|
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
|
||||||
|
if raw, exists := settings["clients"]; exists && raw == nil {
|
||||||
|
settings["clients"] = []any{}
|
||||||
|
}
|
||||||
clients, ok := settings["clients"].([]any)
|
clients, ok := settings["clients"].([]any)
|
||||||
if ok {
|
if ok {
|
||||||
// Fix Client configuration problems
|
// Fix Client configuration problems
|
||||||
var newClients []any
|
newClients := make([]any, 0, len(clients))
|
||||||
hasVisionFlow := false
|
hasVisionFlow := false
|
||||||
for client_index := range clients {
|
for client_index := range clients {
|
||||||
c := clients[client_index].(map[string]any)
|
c := clients[client_index].(map[string]any)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue