Răsfoiți Sursa

Client/inbound resilience + Postgres pool tuning + schema fixes (#4607)

* fix(clients): fall back to inbound scan when ClientRecord is missing

DeleteByEmail looked up the email in client_records and returned the
raw "record not found" gorm error when nothing matched, even though
the client could still live inside an inbound's settings.clients JSON
(legacy entries that SyncInbound never picked up, or rows deleted out
from under a stale inbound). The user-visible delete then fails
mysteriously while xray happily keeps serving the client.

When GetRecordByEmail returns ErrRecordNotFound, walk inbounds whose
settings JSON references the email and run DelInboundClientByEmail on
each. The traffic / IP rows are cleaned up at the end unless keepTraffic
is set. If no inbound carries the email either, surface a clear
"client %q not found in any inbound or client record" error instead.

* chore(logging): include request + caller context in jsonMsgObj warnings

The generic "X-UI: Something went wrong. Error: record not found" log
gave no clue about which endpoint, client, or controller line emitted
it. Prepend a context block:

  [POST /panel/api/clients/del/ADMIN ip=109.124.234.127
   handler=controller.(*ClientController).delete client.go:146]

Handler frame is located by scanning the stack for the first caller
outside util.go, so it points at the right controller method whether
the path went through jsonMsg, jsonObj, or jsonMsgObj directly.

* fix(clients): tolerate orphan client_inbounds rows in Delete

DeleteByEmail's previous fix only covered the case where GetRecordByEmail
returned ErrRecordNotFound. When the ClientRecord exists but a client_inbounds
row points to an inbound that has been removed out-of-band (failed mid-delete,
manual SQL, pre-SyncInbound migration), Delete bubbled the raw gorm
"record not found" from inboundSvc.GetInbound and aborted before any cleanup
ran — leaving the client un-deletable through the UI/API.

Match the tolerance bulkDelInboundClients already has: when GetInbound
returns gorm.ErrRecordNotFound for a join row, log a warning and continue.
The unconditional Delete(&model.ClientInbound{}) later in the function then
removes the stale row, and the ClientRecord delete succeeds.

* fix(schemas): accept empty-string fingerprint on externalProxy

The External Proxy form offers a "Default" option with value '' for the
uTLS fingerprint dropdown, but UtlsFingerprintSchema.optional() rejects
empty strings (only undefined or a valid enum member). Saving an inbound
with externalProxy rows failed with `expected one of "360"|"chrome"|...`.

Preprocess '' to undefined before the optional enum, matching the existing
pattern used for VmessSecuritySchema.

* chore(logging): drop noisy orphan client_inbounds warning

Per-row WARNINGs spammed logs whenever a client referenced multiple
already-deleted inbounds. The continue keeps the orphan-tolerant
behavior; just no longer announces each skipped row.

* feat(clients): per-client VMess security in client form

Restores the VMess `security` selector on the client form (auto, aes-128-gcm,
chacha20-poly1305, none, zero) and surfaces it only when at least one attached
inbound is VMess. The value rides into the share link via the existing
`scy=` field in genVmessLink; the panel persists it on ClientRecord and in
the inbound's settings.clients so the link generator can read it back.

Adds the pages.clients.vmessSecurity i18n key in en-US and fa-IR.

* fix(xray-config): strip panel-only fields from inbound config

Two fields the panel stores but Xray doesn't accept on the inbound side:

- VMess clients[].security — panel persists it so the share-link generator
  can write `scy=...`, but xray's vmess inbound spec has no per-client
  security. The field was leaking into the inbound JSON pushed to xray-core.
- VLESS settings.encryption — per the xray spec the inbound only takes
  `decryption`; `encryption` is for the matching client outbound. The panel
  keeps it for operator reference, but it must not appear in the inbound
  payload.

Add two strip helpers next to HealShadowsocksClientMethods and wire them
into GenXrayInboundConfig via a per-protocol switch, so both local and
remote runtime paths get the cleaned config.

* chore(db): backend-aware pool sizes with env overrides

Per-backend defaults:
- Postgres: 25 max open / 25 max idle. Matching idle to open removes
  pool churn under bursts (Postgres handles concurrency at the server,
  idle connections are cheap).
- SQLite: 1 max open / 1 max idle. Single-writer model means a wider
  cap just queues behind busy_timeout; tight cap is honest.

Both back ends share ConnMaxLifetime=1h and ConnMaxIdleTime=30m so
stale connections (vault rotation, pgbouncer drops, load-balancer
idle eviction) rotate out without operator intervention.

Operators can override either default at boot via:
  XUI_DB_MAX_OPEN_CONNS=...
  XUI_DB_MAX_IDLE_CONNS=...

envInt parses these; missing/empty/non-positive values fall back to
the per-backend default.

* fix(schemas): accept boolean acceptProxyProtocol on TCP stream

TcpStreamSettingsSchema declared `acceptProxyProtocol: z.literal(true).optional()`,
so saving an inbound where the AntD Switch sat in the off state failed
validation with `Invalid input` because the Switch always emits a plain
boolean.

Switch to `z.boolean().default(false)` — same shape ws/sockopt/httpupgrade
already use, and matches the actual wire payload (golden fixtures and
other settings blocks all store `acceptProxyProtocol: false`).

Snapshots for stream.test and inbound-full.test pick up the new defaulted
field on TCP fixtures.
Sanaei 9 ore în urmă
părinte
comite
272854df91

+ 24 - 2
database/db.go

@@ -409,9 +409,19 @@ func InitDB(dbPath string) error {
 	if err != nil {
 		return err
 	}
-	sqlDB.SetMaxOpenConns(8)
-	sqlDB.SetMaxIdleConns(4)
+	var maxOpen, maxIdle int
+	switch config.GetDBKind() {
+	case "postgres":
+		maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 25)
+		maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 25)
+	default:
+		maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 8)
+		maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 4)
+	}
+	sqlDB.SetMaxOpenConns(maxOpen)
+	sqlDB.SetMaxIdleConns(maxIdle)
 	sqlDB.SetConnMaxLifetime(time.Hour)
+	sqlDB.SetConnMaxIdleTime(30 * time.Minute)
 
 	if err := initModels(); err != nil {
 		return err
@@ -428,6 +438,18 @@ func InitDB(dbPath string) error {
 	return runSeeders(isUsersEmpty)
 }
 
+func envInt(key string, def int) int {
+	v := strings.TrimSpace(os.Getenv(key))
+	if v == "" {
+		return def
+	}
+	n, err := strconv.Atoi(v)
+	if err != nil || n <= 0 {
+		return def
+	}
+	return n
+}
+
 // CloseDB closes the database connection if it exists.
 func CloseDB() error {
 	if db != nil {

+ 63 - 1
database/model/model.go

@@ -220,10 +220,19 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen = fmt.Sprintf("\"%v\"", listen)
 	protocol := string(i.Protocol)
 	settings := i.Settings
-	if i.Protocol == Shadowsocks {
+	switch i.Protocol {
+	case Shadowsocks:
 		if healed, ok := HealShadowsocksClientMethods(settings); ok {
 			settings = healed
 		}
+	case VMESS:
+		if stripped, ok := StripVmessClientSecurity(settings); ok {
+			settings = stripped
+		}
+	case VLESS:
+		if stripped, ok := StripVlessInboundEncryption(settings); ok {
+			settings = stripped
+		}
 	}
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
@@ -236,6 +245,59 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	}
 }
 
+func StripVmessClientSecurity(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if _, has := cm["security"]; has {
+			delete(cm, "security")
+			clients[i] = cm
+			changed = true
+		}
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
+func StripVlessInboundEncryption(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	if _, has := parsed["encryption"]; !has {
+		return settings, false
+	}
+	delete(parsed, "encryption")
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // HealShadowsocksClientMethods normalises the per-client `method` field
 // on a shadowsocks inbound's settings JSON before it leaves for xray-core:
 //   - Legacy ciphers (aes-*, chacha20-*): every client must carry a

+ 30 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -26,6 +26,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
@@ -77,6 +78,7 @@ interface FormState {
   password: string;
   auth: string;
   flow: string;
+  security: string;
   reverseTag: string;
   totalGB: number;
   expiryDate: Dayjs | null;
@@ -99,6 +101,7 @@ function emptyForm(): FormState {
     password: '',
     auth: '',
     flow: '',
+    security: 'auto',
     reverseTag: '',
     totalGB: 0,
     expiryDate: null,
@@ -163,6 +166,7 @@ export default function ClientFormModal({
         password: client.password || '',
         auth: client.auth || '',
         flow: client.flow || '',
+        security: client.security || 'auto',
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
@@ -214,6 +218,14 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
+  const vmessIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'vmess') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
   const showFlow = useMemo(
     () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
     [form.inboundIds, flowCapableIds],
@@ -224,6 +236,11 @@ export default function ClientFormModal({
     [form.inboundIds, vlessLikeIds],
   );
 
+  const showSecurity = useMemo(
+    () => (form.inboundIds || []).some((id) => vmessIds.has(id)),
+    [form.inboundIds, vmessIds],
+  );
+
   useEffect(() => {
     if (!showFlow && form.flow) {
 
@@ -286,6 +303,7 @@ export default function ClientFormModal({
       password: form.password,
       auth: form.auth,
       flow: form.flow,
+      security: form.security,
       reverseTag: form.reverseTag,
       totalGB: form.totalGB,
       delayedStart: form.delayedStart,
@@ -313,6 +331,7 @@ export default function ClientFormModal({
       password: form.password,
       auth: form.auth,
       flow: showFlow ? (form.flow || '') : '',
+      security: showSecurity ? (form.security || 'auto') : 'auto',
       totalGB: gbToBytes(form.totalGB),
       expiryTime,
       reset: Number(form.reset) || 0,
@@ -497,6 +516,17 @@ export default function ClientFormModal({
                 </Form.Item>
               </Col>
             )}
+            {showSecurity && (
+              <Col xs={24} md={12}>
+                <Form.Item label={t('pages.clients.vmessSecurity')}>
+                  <Select
+                    value={form.security}
+                    onChange={(v) => update('security', v)}
+                    options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                  />
+                </Form.Item>
+              </Col>
+            )}
           </Row>
 
           <Row gutter={16}>

+ 1 - 0
frontend/src/schemas/client.ts

@@ -113,6 +113,7 @@ export const ClientFormSchema = z.object({
   password: z.string(),
   auth: z.string(),
   flow: z.string(),
+  security: z.string(),
   reverseTag: z.string(),
   totalGB: z.number().min(0),
   delayedStart: z.boolean(),

+ 4 - 1
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -17,7 +17,10 @@ export const ExternalProxyEntrySchema = z.object({
   port: PortSchema.default(443),
   remark: z.string().default(''),
   sni: z.string().optional(),
-  fingerprint: UtlsFingerprintSchema.optional(),
+  fingerprint: z.preprocess(
+    (val) => (val === '' ? undefined : val),
+    UtlsFingerprintSchema.optional(),
+  ),
   alpn: z.array(AlpnSchema).optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 1 - 3
frontend/src/schemas/protocols/stream/tcp.ts

@@ -38,10 +38,8 @@ export const TcpHeaderSchema = z.discriminatedUnion('type', [
 ]);
 export type TcpHeader = z.infer<typeof TcpHeaderSchema>;
 
-// Top-level TCP stream payload. `acceptProxyProtocol` only appears on the
-// wire when true (panel omits it when false), so we treat it as optional.
 export const TcpStreamSettingsSchema = z.object({
-  acceptProxyProtocol: z.literal(true).optional(),
+  acceptProxyProtocol: z.boolean().default(false),
   header: TcpHeaderSchema.optional(),
 });
 export type TcpStreamSettings = z.infer<typeof TcpStreamSettingsSchema>;

+ 6 - 1
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -43,7 +43,9 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
   "streamSettings": {
     "network": "tcp",
     "security": "tls",
-    "tcpSettings": {},
+    "tcpSettings": {
+      "acceptProxyProtocol": false,
+    },
     "tlsSettings": {
       "alpn": [
         "h3",
@@ -125,6 +127,7 @@ exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably
     "network": "tcp",
     "security": "none",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },
@@ -292,6 +295,7 @@ exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`
     },
     "security": "reality",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },
@@ -434,6 +438,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
     "network": "tcp",
     "security": "tls",
     "tcpSettings": {
+      "acceptProxyProtocol": false,
       "header": {
         "type": "none",
       },

+ 3 - 1
frontend/src/test/__snapshots__/stream.test.ts.snap

@@ -14,7 +14,9 @@ exports[`NetworkSettingsSchema fixtures > parses grpc-basic byte-stably 1`] = `
 exports[`NetworkSettingsSchema fixtures > parses tcp-none byte-stably 1`] = `
 {
   "network": "tcp",
-  "tcpSettings": {},
+  "tcpSettings": {
+    "acceptProxyProtocol": false,
+  },
 }
 `;
 

+ 33 - 3
web/controller/util.go

@@ -5,6 +5,8 @@ import (
 	"net"
 	"net/http"
 	"net/netip"
+	"path/filepath"
+	"runtime"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/logger"
@@ -125,6 +127,32 @@ func jsonObj(c *gin.Context, obj any, err error) {
 	jsonMsgObj(c, "", obj, err)
 }
 
+func requestErrorContext(c *gin.Context) string {
+	handler, loc := callerOutsideUtil()
+	return fmt.Sprintf("[%s %s handler=%s %s]", c.Request.Method, c.Request.URL.Path, handler, loc)
+}
+
+func callerOutsideUtil() (string, string) {
+	var pcs [12]uintptr
+	n := runtime.Callers(2, pcs[:])
+	frames := runtime.CallersFrames(pcs[:n])
+	for {
+		frame, more := frames.Next()
+		base := filepath.Base(frame.File)
+		if base != "util.go" {
+			name := frame.Function
+			if idx := strings.LastIndex(name, "/"); idx >= 0 {
+				name = name[idx+1:]
+			}
+			return name, fmt.Sprintf("%s:%d", base, frame.Line)
+		}
+		if !more {
+			break
+		}
+	}
+	return "unknown", "unknown"
+}
+
 // jsonMsgObj sends a JSON response with a message, object, and error status.
 func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 	m := entity.Msg{
@@ -137,16 +165,18 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 		}
 	} else {
 		m.Success = false
+		ctx := requestErrorContext(c)
+		fail := I18nWeb(c, "fail")
 		errStr := err.Error()
 		if errStr != "" {
 			m.Msg = msg + " (" + errStr + ")"
-			logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+			logger.Warningf("%s %s %s: %v", ctx, msg, fail, err)
 		} else if msg != "" {
 			m.Msg = msg
-			logger.Warning(msg + " " + I18nWeb(c, "fail"))
+			logger.Warningf("%s %s %s", ctx, msg, fail)
 		} else {
 			m.Msg = I18nWeb(c, "somethingWentWrong")
-			logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
+			logger.Warningf("%s %s %s", ctx, m.Msg, fail)
 		}
 	}
 	c.JSON(http.StatusOK, m)

+ 69 - 2
web/service/client.go

@@ -678,6 +678,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	for _, ibId := range inboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
 		if getErr != nil {
+			if errors.Is(getErr, gorm.ErrRecordNotFound) {
+				continue
+			}
 			return needRestart, getErr
 		}
 		key := clientKeyForProtocol(inbound.Protocol, existing)
@@ -804,10 +807,74 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 		return false, common.NewError("client email is required")
 	}
 	rec, err := s.GetRecordByEmail(nil, email)
-	if err != nil {
+	if err == nil {
+		return s.Delete(inboundSvc, rec.Id, keepTraffic)
+	}
+	if !errors.Is(err, gorm.ErrRecordNotFound) {
 		return false, err
 	}
-	return s.Delete(inboundSvc, rec.Id, keepTraffic)
+	inboundIds, idsErr := s.findInboundIdsByClientEmail(email)
+	if idsErr != nil {
+		return false, idsErr
+	}
+	if len(inboundIds) == 0 {
+		return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
+	}
+	needRestart := false
+	for _, ibId := range inboundIds {
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	if !keepTraffic {
+		db := database.GetDB()
+		if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+			return needRestart, err
+		}
+		if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
+			return needRestart, err
+		}
+	}
+	return needRestart, nil
+}
+
+// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
+// JSON contains an entry with the given email. Driver-portable (no JSON
+// operators) by parsing in Go — fine for the rare fallback path.
+func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
+	var inbounds []model.Inbound
+	if err := database.GetDB().
+		Select("id, settings").
+		Where("settings LIKE ?", "%"+email+"%").
+		Find(&inbounds).Error; err != nil {
+		return nil, err
+	}
+	out := make([]int, 0, len(inbounds))
+	for _, ib := range inbounds {
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		for _, c := range clients {
+			cm, ok := c.(map[string]any)
+			if !ok {
+				continue
+			}
+			if cEmail, _ := cm["email"].(string); cEmail == email {
+				out = append(out, ib.Id)
+				break
+			}
+		}
+	}
+	return out, nil
 }
 
 func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {

+ 1 - 0
web/translation/en-US.json

@@ -545,6 +545,7 @@
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "flow": "Flow",
+      "vmessSecurity": "VMess Security",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Optional reverse tag",
       "telegramId": "Telegram user ID",

+ 1 - 0
web/translation/fa-IR.json

@@ -509,6 +509,7 @@
       "hysteriaAuth": "Auth (هیستریا)",
       "uuid": "UUID",
       "flow": "Flow",
+      "vmessSecurity": "امنیت VMess",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",