From 272854df9119798a6dac4a78998f815648993371 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 27 May 2026 22:51:37 +0200 Subject: [PATCH] Client/inbound resilience + Postgres pool tuning + schema fixes (#4607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- database/db.go | 26 ++++++- database/model/model.go | 64 ++++++++++++++++- .../src/pages/clients/ClientFormModal.tsx | 30 ++++++++ frontend/src/schemas/client.ts | 1 + .../protocols/stream/external-proxy.ts | 5 +- frontend/src/schemas/protocols/stream/tcp.ts | 4 +- .../__snapshots__/inbound-full.test.ts.snap | 7 +- .../test/__snapshots__/stream.test.ts.snap | 4 +- web/controller/util.go | 36 +++++++++- web/service/client.go | 71 ++++++++++++++++++- web/translation/en-US.json | 1 + web/translation/fa-IR.json | 1 + 12 files changed, 236 insertions(+), 14 deletions(-) diff --git a/database/db.go b/database/db.go index 114440ae..6685db1d 100644 --- a/database/db.go +++ b/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 { diff --git a/database/model/model.go b/database/model/model.go index 411b74c2..f9e333df 100644 --- a/database/model/model.go +++ b/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 diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index c2f83de3..190048f5 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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(); + 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({ )} + {showSecurity && ( + + +