Jelajahi Sumber

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.
MHSanaei 9 jam lalu
induk
melakukan
b42a4d93fc
4 mengubah file dengan 156 tambahan dan 28 penghapusan
  1. 105 1
      database/db.go
  2. 27 9
      frontend/src/lib/xray/inbound-form-adapter.ts
  3. 20 17
      web/service/client.go
  4. 4 1
      web/service/inbound.go

+ 105 - 1
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

+ 27 - 9
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -112,10 +112,26 @@ function healStreamNetworkKey(stream: Record<string, unknown>): 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<string, unknown>): Record<string, unknown>[] {
+  const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
+  return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
+}
+
+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 {
   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<string, unknown>);
+    synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
   }
   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<string, unknown>)
     : undefined;
+  if (streamPruned) stripTlsCertUseFile(streamPruned);
   dropLegacyOptionalEmpties(settingsPruned, streamPruned);
   const payload: WireInboundPayload = {
     up: values.up,

+ 20 - 17
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 {

+ 4 - 1
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)