13 Commits a0989e0f4d ... 5a7b3b7370

Autor SHA1 Mensaje Fecha
  MHSanaei 5a7b3b7370 fix(client): stop duplicate client entries accumulating in inbound settings hace 21 horas
  MHSanaei 9d1a21b484 fix(ui): keep an explicit zero happy-eyeballs delay across the round trip hace 21 horas
  MHSanaei 0753f5ee83 fix(link): reject non-finite and clamp out-of-range quicParams from fm= hace 21 horas
  MHSanaei 837cf5f24e fix(db): clamp traffic counters below int64 max and repair overflowed rows hace 22 horas
  MHSanaei b1fa76f9b6 fix(node): fully delete clients on nodes instead of only detaching them hace 22 horas
  MHSanaei b6873c7a73 fix(outbound): measure HTTP test delay on a warm connection hace 22 horas
  MHSanaei b6183271da fix(tgbot): find clients by tgId regardless of settings JSON formatting hace 22 horas
  MHSanaei 11e45e81b6 fix(link): sanitize numeric quicParams taken from a share link's fm= param hace 22 horas
  MHSanaei 579a9daaa0 fix(ui): make the Happy Eyeballs toggle produce a config xray actually enables hace 22 horas
  MHSanaei 0add63984f fix(ui): align the subUpdates limit with the backend and show the range hace 22 horas
  MHSanaei b6d1caf95d fix(script): rename the Xray binary to xray-linux-arm32 on 32-bit ARM hace 22 horas
  MHSanaei 1bf9e5d544 fix(script): make local PostgreSQL and fail2ban setup work on RHEL-family distros hace 22 horas
  MHSanaei 26e88c7b10 fix(script): stop running full system upgrades via pacman -Syu on Arch hace 23 horas
Se han modificado 38 ficheros con 1189 adiciones y 64 borrados
  1. 3 2
      frontend/src/hooks/useXraySetting.ts
  2. 63 0
      frontend/src/lib/xray/outbound-link-parser.ts
  3. 0 1
      frontend/src/lib/xray/stream-wire-normalize.ts
  4. 1 1
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  5. 1 1
      frontend/src/schemas/protocols/stream/sockopt.ts
  6. 1 1
      frontend/src/schemas/setting.ts
  7. 46 0
      frontend/src/test/outbound-link-parser.test.ts
  8. 17 0
      frontend/src/test/setting-sub-updates.test.ts
  9. 17 0
      frontend/src/test/stream-wire-normalize.test.ts
  10. 40 5
      install.sh
  11. 111 0
      internal/database/db.go
  12. 13 0
      internal/database/dialect.go
  13. 78 0
      internal/database/inbound_dedupe_test.go
  14. 103 0
      internal/database/traffic_overflow_repair_test.go
  15. 82 0
      internal/util/link/outbound.go
  16. 78 0
      internal/util/link/outbound_test.go
  17. 4 0
      internal/web/runtime/local.go
  18. 16 0
      internal/web/runtime/remote.go
  19. 7 0
      internal/web/runtime/runtime.go
  20. 95 0
      internal/web/service/client_add_dedupe_test.go
  21. 4 1
      internal/web/service/client_bulk.go
  22. 3 3
      internal/web/service/client_crud.go
  23. 66 0
      internal/web/service/client_delete_node_full_test.go
  24. 50 8
      internal/web/service/client_inbound_apply.go
  25. 43 0
      internal/web/service/client_locks.go
  26. 1 1
      internal/web/service/client_wireguard_crud_test.go
  27. 1 1
      internal/web/service/del_shared_email_runtime_test.go
  28. 6 1
      internal/web/service/inbound_node.go
  29. 67 0
      internal/web/service/inbound_tgbot_lookup_test.go
  30. 23 8
      internal/web/service/inbound_traffic.go
  31. 9 3
      internal/web/service/node_bulk_dispatch_test.go
  32. 1 1
      internal/web/service/node_dirty_test.go
  33. 16 5
      internal/web/service/outbound/outbound.go
  34. 55 14
      internal/web/service/outbound/probe_http.go
  35. 19 0
      internal/web/service/outbound/probe_http_test.go
  36. 1 1
      internal/web/service/sync_scale_postgres_test.go
  37. 7 4
      update.sh
  38. 41 2
      x-ui.sh

+ 3 - 2
frontend/src/hooks/useXraySetting.ts

@@ -17,8 +17,9 @@ import {
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 // One HTTP-mode batch request tests this many outbounds through a single
-// shared temp xray instance; chunking keeps responses bounded (~15s worst
-// case) and lands Test All results progressively.
+// shared temp xray instance; chunking keeps responses bounded (~30s worst
+// case — each probe is a cold plus a warm request) and lands Test All
+// results progressively.
 const HTTP_BATCH_CHUNK = 16;
 
 export function isUdpOutbound(outbound: unknown): boolean {

+ 63 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -218,6 +218,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
   try {
     const parsed = JSON.parse(fm) as Record<string, unknown>;
     if (parsed && typeof parsed === 'object') {
+      sanitizeFinalMaskQuicParams(parsed);
       stream.finalmask = parsed;
     }
   } catch {
@@ -225,6 +226,68 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
   }
 }
 
+const QUIC_PARAMS_NUMERIC_KEYS = [
+  'initStreamReceiveWindow',
+  'maxStreamReceiveWindow',
+  'initConnectionReceiveWindow',
+  'maxConnectionReceiveWindow',
+  'maxIdleTimeout',
+  'keepAlivePeriod',
+  'maxIncomingStreams',
+] as const;
+
+const DURATION_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3600 };
+
+const QUIC_NUMERIC_MAX = 1e15;
+
+function coerceQuicNumeric(value: unknown): number | null {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return Math.trunc(value);
+  }
+  if (typeof value === 'string') {
+    const asNumber = Number(value);
+    if (value.trim() !== '' && Number.isFinite(asNumber)) {
+      return Math.trunc(asNumber);
+    }
+    const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim());
+    if (duration) {
+      return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]);
+    }
+  }
+  return null;
+}
+
+function clampQuicNumeric(key: string, n: number): number | null {
+  if (n < 0 || n > QUIC_NUMERIC_MAX) return null;
+  if (n === 0) return 0;
+  if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60);
+  if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120);
+  if (key === 'maxIncomingStreams') return Math.max(n, 8);
+  return n;
+}
+
+// xray-core rejects the whole config when these quicParams fields are not
+// plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60,
+// maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce
+// numeric/duration strings, clamp the ranged fields, and drop anything
+// unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in
+// internal/util/link/outbound.go.
+function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
+  const raw = parsed.quicParams;
+  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
+  const quic = raw as Record<string, unknown>;
+  for (const key of QUIC_PARAMS_NUMERIC_KEYS) {
+    if (!(key in quic)) continue;
+    const coerced = coerceQuicNumeric(quic[key]);
+    const clamped = coerced === null ? null : clampQuicNumeric(key, coerced);
+    if (clamped === null) {
+      delete quic[key];
+      continue;
+    }
+    quic[key] = clamped;
+  }
+}
+
 function applySecurityParams(stream: Raw, params: URLSearchParams): void {
   if (stream.security === 'tls') {
     const tls = stream.tlsSettings as Raw;

+ 0 - 1
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -267,7 +267,6 @@ export function normalizeSockoptForWire(
   const he = out.happyEyeballs;
   if (isRecord(he)) {
     const heOut: Record<string, unknown> = { ...he };
-    if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
     if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
     if (heOut.interleave === 1) delete heOut.interleave;
     if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;

+ 1 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -79,7 +79,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
-              <InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
+              <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
                 onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
             </SettingListItem>
           </>

+ 1 - 1
frontend/src/schemas/protocols/stream/sockopt.ts

@@ -33,7 +33,7 @@ export const AddressPortStrategySchema = z.enum([
 export type AddressPortStrategy = z.infer<typeof AddressPortStrategySchema>;
 
 export const HappyEyeballsSchema = z.object({
-  tryDelayMs: z.number().int().min(0).default(0),
+  tryDelayMs: z.number().int().min(0).default(250),
   prioritizeIPv6: z.boolean().default(false),
   interleave: z.number().int().min(1).default(1),
   maxConcurrentTry: z.number().int().min(0).default(4),

+ 1 - 1
frontend/src/schemas/setting.ts

@@ -53,7 +53,7 @@ export const AllSettingSchema = z.object({
   restartXrayOnClientDisable: z.boolean().optional(),
   subCertFile: z.string().optional(),
   subKeyFile: z.string().optional(),
-  subUpdates: z.number().int().min(1).max(168).optional(),
+  subUpdates: z.number().int().min(0).max(525600).optional(),
   subEncrypt: z.boolean().optional(),
   subURI: z.string().optional(),
   subJsonURI: z.string().optional(),

+ 46 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -308,6 +308,52 @@ describe('parseHysteria2Link', () => {
     expect(settings.packetSize).toBe('100-200');
   });
 
+  it('coerces string quicParams numerics under fm to integers — #5783', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      quicParams: {
+        keepAlivePeriod: '10s',
+        maxIdleTimeout: '30',
+        initStreamReceiveWindow: 524288,
+        maxIncomingStreams: true,
+        brutalUp: '100 mbps',
+      },
+    }));
+    const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-quic`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const quic = finalmask.quicParams as Record<string, unknown>;
+    expect(quic.keepAlivePeriod).toBe(10);
+    expect(quic.maxIdleTimeout).toBe(30);
+    expect(quic.initStreamReceiveWindow).toBe(524288);
+    expect('maxIncomingStreams' in quic).toBe(false);
+    expect(quic.brutalUp).toBe('100 mbps');
+  });
+
+  it('clamps quicParams to the ranges xray accepts and drops junk — #5783', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      quicParams: {
+        keepAlivePeriod: '1s',
+        maxIdleTimeout: '10m',
+        maxIncomingStreams: 4,
+        initStreamReceiveWindow: 'inf',
+        maxStreamReceiveWindow: -5,
+        initConnectionReceiveWindow: 1e30,
+      },
+    }));
+    const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-clamp`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const quic = finalmask.quicParams as Record<string, unknown>;
+    expect(quic.keepAlivePeriod).toBe(2);
+    expect(quic.maxIdleTimeout).toBe(120);
+    expect(quic.maxIncomingStreams).toBe(8);
+    expect('initStreamReceiveWindow' in quic).toBe(false);
+    expect('maxStreamReceiveWindow' in quic).toBe(false);
+    expect('initConnectionReceiveWindow' in quic).toBe(false);
+  });
+
   it('round-trips the realm tlsConfig under fm', () => {
     const fm = encodeURIComponent(JSON.stringify({
       udp: [{

+ 17 - 0
frontend/src/test/setting-sub-updates.test.ts

@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+
+describe('subUpdates range', () => {
+  it('accepts values the backend allows (gte=0, lte=525600)', () => {
+    for (const v of [0, 12, 168, 720, 525600]) {
+      const r = AllSettingSchema.safeParse({ subUpdates: v });
+      expect(r.success, `subUpdates=${v} should be valid`).toBe(true);
+    }
+  });
+
+  it('rejects values outside the backend range', () => {
+    for (const v of [-1, 525601, 1.5]) {
+      expect(AllSettingSchema.safeParse({ subUpdates: v }).success, `subUpdates=${v} should be invalid`).toBe(false);
+    }
+  });
+});

+ 17 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -10,6 +10,7 @@ import {
   validateRealityTarget,
 } from '@/lib/xray/stream-wire-normalize';
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
+import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 
@@ -204,6 +205,22 @@ describe('normalizeSockoptForWire', () => {
     });
     expect(out?.domainStrategy).toBe('UseIP');
   });
+
+  it('keeps a freshly toggled happyEyeballs (schema defaults) across the wire round trip', () => {
+    const out = normalizeSockoptForWire({
+      happyEyeballs: HappyEyeballsSchema.parse({}),
+    });
+
+    expect(out?.happyEyeballs).toEqual({ tryDelayMs: 250 });
+  });
+
+  it('keeps an explicit tryDelayMs of 0 so it cannot rehydrate as the 250 default', () => {
+    const out = normalizeSockoptForWire({
+      happyEyeballs: { tryDelayMs: 0, prioritizeIPv6: true, interleave: 1, maxConcurrentTry: 4 },
+    });
+
+    expect(out?.happyEyeballs).toEqual({ tryDelayMs: 0, prioritizeIPv6: true });
+  });
 });
 
 describe('normalizeStreamSettingsForWire reality', () => {

+ 40 - 5
install.sh

@@ -111,7 +111,7 @@ install_base() {
             fi
             ;;
         arch | manjaro | parch)
-            pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
+            pacman -Sy --noconfirm cronie curl tar tzdata socat ca-certificates openssl
             ;;
         opensuse-tumbleweed | opensuse-leap)
             zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
@@ -180,6 +180,36 @@ write_install_result() {
     echo -e "${green}Install result written to ${result_file} (mode 600).${plain}"
 }
 
+# RHEL-family initdb writes pg_hba.conf host rules with ident auth, which
+# compares the OS username against the Postgres role and always rejects the
+# randomly generated panel role over TCP (#5806). Prepend password-auth rules
+# scoped to the panel database; first match wins, and md5 also accepts
+# scram-sha-256-stored verifiers, so this works on every supported distro.
+pg_ensure_hba_password_auth() {
+    local pg_db="$1"
+    local hba_file
+    hba_file=$(sudo -u postgres psql -tAc 'SHOW hba_file' 2> /dev/null | tr -d '[:space:]')
+    [[ -n "${hba_file}" && -f "${hba_file}" ]] || return 0
+    grep -Eq "^host[[:space:]]+${pg_db}[[:space:]]" "${hba_file}" && return 0
+    local tmp
+    tmp=$(mktemp) || return 1
+    {
+        echo "# Added by 3x-ui: allow password logins for the panel database."
+        echo "host    ${pg_db}    all    127.0.0.1/32    md5"
+        echo "host    ${pg_db}    all    ::1/128         md5"
+        cat "${hba_file}"
+    } > "${tmp}" || {
+        rm -f "${tmp}"
+        return 1
+    }
+    cat "${tmp}" > "${hba_file}" || {
+        rm -f "${tmp}"
+        return 1
+    }
+    rm -f "${tmp}"
+    sudo -u postgres psql -tAc 'SELECT pg_reload_conf()' > /dev/null 2>&1 || true
+}
+
 install_postgres_local() {
     local pg_user pg_pass
     pg_pass=$(gen_random_string 24)
@@ -204,7 +234,7 @@ install_postgres_local() {
             [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
             ;;
         arch | manjaro | parch)
-            pacman -Syu --noconfirm postgresql >&2 || return 1
+            pacman -Sy --noconfirm postgresql >&2 || return 1
             if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
                 sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
             fi
@@ -263,6 +293,9 @@ install_postgres_local() {
 
     sudo -u postgres psql -c "ALTER USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
 
+    pg_ensure_hba_password_auth "${pg_db}" \
+        || echo -e "${yellow}Warning: could not update pg_hba.conf; PostgreSQL may reject the panel's TCP login (ident auth).${plain}" >&2
+
     local pg_pass_enc
     pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')
 
@@ -1460,10 +1493,12 @@ install_x-ui() {
     chmod +x x-ui
     chmod +x x-ui.sh
 
-    # Check the system's architecture and rename the file accordingly
+    # Check the system's architecture and rename the file accordingly.
+    # The panel binary maps GOARCH=arm to "arm32" (internal/xray/process.go),
+    # so the Xray binary must be named xray-linux-arm32; mtg keeps plain "arm".
     if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
-        mv bin/xray-linux-$(arch) bin/xray-linux-arm
-        chmod +x bin/xray-linux-arm
+        mv bin/xray-linux-$(arch) bin/xray-linux-arm32
+        chmod +x bin/xray-linux-arm32
         if [[ -f bin/mtg-linux-$(arch) ]]; then
             mv bin/mtg-linux-$(arch) bin/mtg-linux-arm
             chmod +x bin/mtg-linux-arm

+ 111 - 0
internal/database/db.go

@@ -113,6 +113,12 @@ func initModels() error {
 	if err := normalizeInboundSubSortIndex(); err != nil {
 		return err
 	}
+	if err := repairOverflowedTrafficCounters(); err != nil {
+		return err
+	}
+	if err := dedupeInboundSettingsClients(); err != nil {
+		return err
+	}
 	if err := migrateLegacySocksInboundsToMixed(); err != nil {
 		return err
 	}
@@ -518,6 +524,111 @@ func normalizeInboundSubSortIndex() error {
 	return nil
 }
 
+// repairOverflowedTrafficCounters heals traffic counters that historic
+// compounding bugs pushed past int64: on SQLite an overflowing INTEGER is
+// silently promoted to REAL, after which the column no longer scans into the
+// Go int64 field and every reader of the table fails (#5762). REAL cells are
+// cast back to INTEGER (SQLite caps the cast at math.MaxInt64), then values
+// are clamped into [0, TrafficMax] on both backends so the next delta cannot
+// overflow again.
+func repairOverflowedTrafficCounters() error {
+	targets := []struct {
+		table   string
+		columns []string
+	}{
+		{"client_traffics", []string{"up", "down"}},
+		{"inbounds", []string{"up", "down"}},
+		{"outbound_traffics", []string{"up", "down", "total"}},
+		{"node_client_traffics", []string{"up", "down"}},
+	}
+	for _, target := range targets {
+		for _, col := range target.columns {
+			statements := []string{
+				fmt.Sprintf("UPDATE %s SET %s = %d WHERE %s > %d", target.table, col, TrafficMax, col, TrafficMax),
+				fmt.Sprintf("UPDATE %s SET %s = 0 WHERE %s < 0", target.table, col, col),
+			}
+			if !IsPostgres() {
+				statements = append([]string{
+					fmt.Sprintf("UPDATE %s SET %s = CAST(%s AS INTEGER) WHERE typeof(%s) = 'real'", target.table, col, col, col),
+				}, statements...)
+			}
+			var repaired int64
+			for _, statement := range statements {
+				res := db.Exec(statement)
+				if res.Error != nil {
+					log.Printf("Error repairing %s.%s: %v", target.table, col, res.Error)
+					return res.Error
+				}
+				repaired += res.RowsAffected
+			}
+			if repaired > 0 {
+				log.Printf("Repaired %d overflowed %s.%s value(s)", repaired, target.table, col)
+			}
+		}
+	}
+	return nil
+}
+
+// dedupeInboundSettingsClients collapses duplicate same-email entries inside
+// every inbound's settings.clients array, keeping the first occurrence.
+// Retried or raced multi-node client adds on older builds appended the same
+// client several times (#5770), which the client lists then rendered as
+// phantom duplicates. Runs on every start (idempotent, writes only changed
+// rows) because a restored backup or a not-yet-upgraded node's snapshot can
+// reintroduce duplicates.
+func dedupeInboundSettingsClients() error {
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+	repaired := int64(0)
+	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 {
+			continue
+		}
+		clients, _ := settings["clients"].([]any)
+		if len(clients) < 2 {
+			continue
+		}
+		seen := make(map[string]struct{}, len(clients))
+		kept := make([]any, 0, len(clients))
+		for _, c := range clients {
+			if cm, ok := c.(map[string]any); ok {
+				if email, _ := cm["email"].(string); email != "" {
+					key := strings.ToLower(email)
+					if _, dup := seen[key]; dup {
+						continue
+					}
+					seen[key] = struct{}{}
+				}
+			}
+			kept = append(kept, c)
+		}
+		if len(kept) == len(clients) {
+			continue
+		}
+		settings["clients"] = kept
+		newSettings, err := json.MarshalIndent(settings, "", "  ")
+		if err != nil {
+			log.Printf("dedupeInboundSettingsClients: skip inbound %d (marshal failed): %v", inbound.Id, err)
+			continue
+		}
+		if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+			Update("settings", string(newSettings)).Error; err != nil {
+			return err
+		}
+		repaired++
+	}
+	if repaired > 0 {
+		log.Printf("Removed duplicate client entries from %d inbound(s)", repaired)
+	}
+	return nil
+}
+
 func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
 	if err == nil {
 		return false

+ 13 - 0
internal/database/dialect.go

@@ -2,6 +2,19 @@ package database
 
 import "fmt"
 
+// TrafficMax caps every traffic counter safely below math.MaxInt64 (~9.22e18)
+// so that one more delta can never overflow int64. SQLite silently promotes an
+// overflowing INTEGER to REAL, after which the column no longer scans into the
+// Go int64 field and every reader of the table fails (#5762).
+const TrafficMax = int64(9_000_000_000_000_000_000)
+
+func ClampedAddExpr(col string) string {
+	if IsPostgres() {
+		return fmt.Sprintf("LEAST(%s + ?, %d)", col, TrafficMax)
+	}
+	return fmt.Sprintf("MIN(%s + ?, %d)", col, TrafficMax)
+}
+
 func JSONClientsFromInbound() string {
 	if IsPostgres() {
 		return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"

+ 78 - 0
internal/database/inbound_dedupe_test.go

@@ -0,0 +1,78 @@
+package database
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestDedupeInboundSettingsClients_CollapsesDuplicateEmails covers the #5770
+// repair: settings.clients arrays written by older builds can carry the same
+// email several times; startup must collapse them to the first occurrence and
+// leave clean inbounds byte-for-byte untouched.
+func TestDedupeInboundSettingsClients_CollapsesDuplicateEmails(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	dupSettings := `{"clients": [` +
+		`{"id": "u1", "email": "dup@x", "subId": "s1", "enable": true},` +
+		`{"id": "u2", "email": "keep@x", "subId": "s2", "enable": true},` +
+		`{"id": "u1", "email": "dup@x", "subId": "s1", "enable": true},` +
+		`{"id": "u1", "email": "dup@x", "subId": "s1", "enable": true}]}`
+	dirty := model.Inbound{UserId: 1, Port: 21001, Protocol: model.VLESS, Tag: "dedupe-dirty", Settings: dupSettings}
+	if err := db.Create(&dirty).Error; err != nil {
+		t.Fatalf("create dirty inbound: %v", err)
+	}
+
+	cleanSettings := `{"clients": [{"id": "u3", "email": "solo@x", "subId": "s3", "enable": true}]}`
+	clean := model.Inbound{UserId: 1, Port: 21002, Protocol: model.VLESS, Tag: "dedupe-clean", Settings: cleanSettings}
+	if err := db.Create(&clean).Error; err != nil {
+		t.Fatalf("create clean inbound: %v", err)
+	}
+
+	if err := dedupeInboundSettingsClients(); err != nil {
+		t.Fatalf("dedupeInboundSettingsClients: %v", err)
+	}
+
+	var gotDirty model.Inbound
+	if err := db.First(&gotDirty, dirty.Id).Error; err != nil {
+		t.Fatalf("reload dirty inbound: %v", err)
+	}
+	var parsed struct {
+		Clients []map[string]any `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(gotDirty.Settings), &parsed); err != nil {
+		t.Fatalf("parse repaired settings: %v", err)
+	}
+	if len(parsed.Clients) != 2 {
+		t.Fatalf("expected 2 clients after dedupe, got %d: %s", len(parsed.Clients), gotDirty.Settings)
+	}
+	if parsed.Clients[0]["email"] != "dup@x" || parsed.Clients[1]["email"] != "keep@x" {
+		t.Fatalf("expected first occurrences [dup@x keep@x], got %v", parsed.Clients)
+	}
+
+	var gotClean model.Inbound
+	if err := db.First(&gotClean, clean.Id).Error; err != nil {
+		t.Fatalf("reload clean inbound: %v", err)
+	}
+	if gotClean.Settings != cleanSettings {
+		t.Fatalf("clean inbound settings were rewritten:\nbefore: %s\nafter:  %s", cleanSettings, gotClean.Settings)
+	}
+
+	if err := dedupeInboundSettingsClients(); err != nil {
+		t.Fatalf("second dedupe run: %v", err)
+	}
+	var again model.Inbound
+	if err := db.First(&again, dirty.Id).Error; err != nil {
+		t.Fatalf("reload after second run: %v", err)
+	}
+	if again.Settings != gotDirty.Settings {
+		t.Fatal("dedupe is not idempotent: settings changed on the second run")
+	}
+}

+ 103 - 0
internal/database/traffic_overflow_repair_test.go

@@ -0,0 +1,103 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestRepairOverflowedTrafficCounters_HealsSQLiteRealPromotion reproduces
+// #5762: a counter pushed past int64 makes SQLite silently store the cell as
+// REAL, after which scanning the row back into the Go int64 field fails and
+// every reader of client_traffics breaks. The startup repair must convert the
+// cell back to a scannable integer clamped to TrafficMax.
+func TestRepairOverflowedTrafficCounters_HealsSQLiteRealPromotion(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	rows := []xray.ClientTraffic{
+		{Email: "overflowed@x", Enable: true, Up: 5, Down: 6},
+		{Email: "negative@x", Enable: true, Up: 7, Down: 8},
+		{Email: "healthy@x", Enable: true, Up: 100, Down: 200},
+	}
+	for i := range rows {
+		if err := db.Create(&rows[i]).Error; err != nil {
+			t.Fatalf("create traffic row %d: %v", i, err)
+		}
+	}
+
+	if err := db.Exec("UPDATE client_traffics SET down = 1.2247589467272907e+19 WHERE email = 'overflowed@x'").Error; err != nil {
+		t.Fatalf("corrupt down: %v", err)
+	}
+	if err := db.Exec("UPDATE client_traffics SET up = -42 WHERE email = 'negative@x'").Error; err != nil {
+		t.Fatalf("corrupt up: %v", err)
+	}
+
+	var broken []xray.ClientTraffic
+	if err := db.Find(&broken).Error; err == nil {
+		t.Fatal("expected the REAL-promoted row to break scanning before the repair")
+	}
+
+	if err := repairOverflowedTrafficCounters(); err != nil {
+		t.Fatalf("repairOverflowedTrafficCounters: %v", err)
+	}
+
+	byEmail := map[string]xray.ClientTraffic{}
+	var repaired []xray.ClientTraffic
+	if err := db.Find(&repaired).Error; err != nil {
+		t.Fatalf("scan after repair: %v", err)
+	}
+	for _, r := range repaired {
+		byEmail[r.Email] = r
+	}
+	if got := byEmail["overflowed@x"].Down; got != TrafficMax {
+		t.Errorf("overflowed down: expected clamp to %d, got %d", TrafficMax, got)
+	}
+	if got := byEmail["overflowed@x"].Up; got != 5 {
+		t.Errorf("overflowed up: expected untouched 5, got %d", got)
+	}
+	if got := byEmail["negative@x"].Up; got != 0 {
+		t.Errorf("negative up: expected clamp to 0, got %d", got)
+	}
+	if got := byEmail["healthy@x"]; got.Up != 100 || got.Down != 200 {
+		t.Errorf("healthy row changed: %+v", got)
+	}
+}
+
+// TestClampedAddExpr_CapsAtTrafficMax verifies the write-path clamp: a delta
+// applied to a counter near the cap must saturate at TrafficMax instead of
+// overflowing int64 (which SQLite would promote to REAL).
+func TestClampedAddExpr_CapsAtTrafficMax(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	row := xray.ClientTraffic{Email: "near-cap@x", Enable: true, Up: TrafficMax - 10, Down: 1}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("create traffic row: %v", err)
+	}
+
+	query := "UPDATE client_traffics SET up = " + ClampedAddExpr("up") + ", down = " + ClampedAddExpr("down") + " WHERE email = ?"
+	if err := db.Exec(query, int64(1_000_000), int64(5), "near-cap@x").Error; err != nil {
+		t.Fatalf("clamped add: %v", err)
+	}
+
+	var got xray.ClientTraffic
+	if err := db.Where("email = ?", "near-cap@x").First(&got).Error; err != nil {
+		t.Fatalf("scan after clamped add: %v", err)
+	}
+	if got.Up != TrafficMax {
+		t.Errorf("up: expected saturation at %d, got %d", TrafficMax, got.Up)
+	}
+	if got.Down != 6 {
+		t.Errorf("down: expected 6, got %d", got.Down)
+	}
+}

+ 82 - 0
internal/util/link/outbound.go

@@ -8,10 +8,12 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"math"
 	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
+	"time"
 )
 
 // Outbound is the minimal shape we emit for each parsed link.
@@ -657,11 +659,91 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 	if fm := p.Get("fm"); fm != "" {
 		var parsed any
 		if json.Unmarshal([]byte(fm), &parsed) == nil {
+			sanitizeFinalMaskQuicParams(parsed)
 			stream["finalmask"] = parsed
 		}
 	}
 }
 
+// sanitizeFinalMaskQuicParams coerces the strictly numeric quicParams fields
+// of a finalmask blob taken verbatim from a share link's fm= parameter.
+// Xray-core rejects the whole config at startup when e.g. keepAlivePeriod
+// arrives as a duration string like "10s" or an out-of-range integer, so
+// numeric strings are parsed, duration strings are converted to whole
+// seconds, the ranged fields are clamped to what xray accepts, and anything
+// non-finite, negative, absurdly large, or unparseable is dropped so a bad
+// value falls back to xray's default instead of killing the config (#5783).
+func sanitizeFinalMaskQuicParams(parsed any) {
+	fm, ok := parsed.(map[string]any)
+	if !ok {
+		return
+	}
+	qp, ok := fm["quicParams"].(map[string]any)
+	if !ok {
+		return
+	}
+	numericKeys := []string{
+		"initStreamReceiveWindow", "maxStreamReceiveWindow",
+		"initConnectionReceiveWindow", "maxConnectionReceiveWindow",
+		"maxIdleTimeout", "keepAlivePeriod", "maxIncomingStreams",
+	}
+	for _, key := range numericKeys {
+		raw, exists := qp[key]
+		if !exists {
+			continue
+		}
+		n, ok := coerceQuicNumeric(raw)
+		if ok {
+			n, ok = clampQuicNumeric(key, n)
+		}
+		if !ok {
+			delete(qp, key)
+			continue
+		}
+		qp[key] = int64(n)
+	}
+}
+
+func coerceQuicNumeric(raw any) (float64, bool) {
+	switch v := raw.(type) {
+	case float64:
+		return math.Trunc(v), true
+	case string:
+		if n, err := strconv.ParseFloat(v, 64); err == nil && !math.IsInf(n, 0) && !math.IsNaN(n) {
+			return math.Trunc(n), true
+		}
+		if d, err := time.ParseDuration(v); err == nil {
+			return math.Trunc(d.Seconds()), true
+		}
+	}
+	return 0, false
+}
+
+// clampQuicNumeric enforces xray-core's QuicParamsConfig validation so a
+// coerced value cannot still fail the config load: keepAlivePeriod is 0 or
+// 2-60, maxIdleTimeout is 0 or 4-120, maxIncomingStreams is 0 or >= 8.
+// quicNumericMax keeps values in plain-integer JSON territory and far below
+// the uint64 window fields' range.
+const quicNumericMax = float64(1e15)
+
+func clampQuicNumeric(key string, n float64) (float64, bool) {
+	if n < 0 || n > quicNumericMax {
+		return 0, false
+	}
+	if n == 0 {
+		return 0, true
+	}
+	switch key {
+	case "keepAlivePeriod":
+		return math.Min(math.Max(n, 2), 60), true
+	case "maxIdleTimeout":
+		return math.Min(math.Max(n, 4), 120), true
+	case "maxIncomingStreams":
+		return math.Max(n, 8), true
+	}
+	return n, true
+}
+
 func firstNonEmpty(a, b string) string {
 	if a != "" {
 		return a

+ 78 - 0
internal/util/link/outbound_test.go

@@ -1,6 +1,7 @@
 package link
 
 import (
+	"net/url"
 	"strings"
 	"testing"
 )
@@ -35,6 +36,83 @@ func TestParseVlessLink(t *testing.T) {
 	}
 }
 
+func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) {
+	fm := url.QueryEscape(`{"mask":"dtls","quicParams":{"keepAlivePeriod":"10s","maxIdleTimeout":"30","initStreamReceiveWindow":524288,"maxIncomingStreams":true,"brutalUp":"100 mbps"}}`)
+	res, err := ParseLink("vless://[email protected]:443?type=tcp&security=none&fm=" + fm + "#node1")
+	if err != nil {
+		t.Fatalf("parse vless with fm: %v", err)
+	}
+	stream, ok := res.Outbound["streamSettings"].(map[string]any)
+	if !ok {
+		t.Fatalf("missing streamSettings: %v", res.Outbound)
+	}
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		t.Fatalf("missing finalmask: %v", stream)
+	}
+	if finalmask["mask"] != "dtls" {
+		t.Errorf("mask changed: %v", finalmask["mask"])
+	}
+	qp, ok := finalmask["quicParams"].(map[string]any)
+	if !ok {
+		t.Fatalf("missing quicParams: %v", finalmask)
+	}
+	if got := qp["keepAlivePeriod"]; got != int64(10) {
+		t.Errorf("keepAlivePeriod: expected 10, got %v (%T)", got, got)
+	}
+	if got := qp["maxIdleTimeout"]; got != int64(30) {
+		t.Errorf("maxIdleTimeout: expected 30, got %v (%T)", got, got)
+	}
+	if got := qp["initStreamReceiveWindow"]; got != int64(524288) {
+		t.Errorf("initStreamReceiveWindow: expected 524288, got %v (%T)", got, got)
+	}
+	if _, exists := qp["maxIncomingStreams"]; exists {
+		t.Errorf("maxIncomingStreams should be dropped, got %v", qp["maxIncomingStreams"])
+	}
+	if got := qp["brutalUp"]; got != "100 mbps" {
+		t.Errorf("brutalUp should stay a string, got %v (%T)", got, got)
+	}
+}
+
+func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
+	cases := []struct {
+		name string
+		key  string
+		in   any
+		want any
+	}{
+		{"infinite string dropped", "keepAlivePeriod", "inf", nil},
+		{"nan string dropped", "keepAlivePeriod", "NaN", nil},
+		{"negative dropped", "maxStreamReceiveWindow", float64(-5), nil},
+		{"negative duration dropped", "keepAlivePeriod", "-10s", nil},
+		{"absurd magnitude dropped", "initConnectionReceiveWindow", float64(1e30), nil},
+		{"keepAlive clamped up", "keepAlivePeriod", "1s", int64(2)},
+		{"keepAlive clamped down", "keepAlivePeriod", "90s", int64(60)},
+		{"idle clamped up", "maxIdleTimeout", float64(1), int64(4)},
+		{"idle clamped down", "maxIdleTimeout", "10m", int64(120)},
+		{"streams clamped up", "maxIncomingStreams", float64(4), int64(8)},
+		{"zero means unset and survives", "maxIdleTimeout", float64(0), int64(0)},
+		{"window passes through", "initStreamReceiveWindow", float64(524288), int64(524288)},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			parsed := map[string]any{"quicParams": map[string]any{c.key: c.in}}
+			sanitizeFinalMaskQuicParams(parsed)
+			qp := parsed["quicParams"].(map[string]any)
+			got, exists := qp[c.key]
+			if c.want == nil {
+				if exists {
+					t.Fatalf("%s: expected key dropped, got %v (%T)", c.key, got, got)
+				}
+				return
+			}
+			if !exists || got != c.want {
+				t.Fatalf("%s: expected %v, got %v (%T)", c.key, c.want, got, got)
+			}
+		})
+	}
+}
+
 func TestParseSubscriptionBody_Base64(t *testing.T) {
 	// base64 of the two joined links:
 	// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B

+ 4 - 0
internal/web/runtime/local.go

@@ -130,6 +130,10 @@ func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string)
 	return nil
 }
 
+func (l *Local) DeleteClient(context.Context, string) error {
+	return nil
+}
+
 func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
 	if oldEmail != "" {
 		if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {

+ 16 - 0
internal/web/runtime/remote.go

@@ -553,6 +553,22 @@ func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string
 	return err
 }
 
+func (r *Remote) DeleteClient(ctx context.Context, email string) error {
+	if email == "" {
+		return nil
+	}
+	_, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/del/"+url.PathEscape(email), nil)
+	if err == nil {
+		return nil
+	}
+	var apiErr *remoteAPIError
+	if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") {
+		return nil
+	}
+	return err
+}
+
 func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
 	if oldEmail == "" {
 		oldEmail = payload.Email

+ 7 - 0
internal/web/runtime/runtime.go

@@ -23,6 +23,13 @@ type Runtime interface {
 	DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
 	AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
 
+	// DeleteClient removes the client identified by email entirely from the
+	// runtime's own store: on Remote it hits the node's full-delete endpoint
+	// (record, attachments, traffic), unlike DeleteUser which only detaches
+	// from one inbound and leaves the node's client record behind. Local has
+	// no client store of its own, so it is a no-op there.
+	DeleteClient(ctx context.Context, email string) error
+
 	RestartXray(ctx context.Context) error
 
 	ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error

+ 95 - 0
internal/web/service/client_add_dedupe_test.go

@@ -0,0 +1,95 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/google/uuid"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func mustUnmarshal(t *testing.T, raw string, v any) {
+	t.Helper()
+	if err := json.Unmarshal([]byte(raw), v); err != nil {
+		t.Fatalf("unmarshal %q: %v", raw, err)
+	}
+}
+
+func settingsClientEmails(t *testing.T, inboundId int) []string {
+	t.Helper()
+	var ib model.Inbound
+	if err := database.GetDB().First(&ib, inboundId).Error; err != nil {
+		t.Fatalf("load inbound %d: %v", inboundId, err)
+	}
+	clients, err := (&InboundService{}).GetClients(&ib)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	emails := make([]string, 0, len(clients))
+	for _, c := range clients {
+		emails = append(emails, c.Email)
+	}
+	return emails
+}
+
+// Re-adding a client that is already on the inbound must be an idempotent
+// no-op, not a second settings entry: checkEmailsExistForClients exempts a
+// matching subId (so one identity can span inbounds), which let retried or
+// raced adds duplicate the same email inside one settings array (#5770).
+func TestAddInboundClient_SkipsClientsAlreadyOnInbound(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, _ := setupNodeRuntime(t)
+
+	alice := model.Client{ID: uuid.NewString(), Email: "alice@dup", SubID: "alice-sub-1234567", Enable: true}
+	ib := nodeInbound(t, nodeID, 33001, []model.Client{alice})
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{alice})}); err != nil {
+		t.Fatalf("re-add of existing client should be a no-op, got error: %v", err)
+	}
+	if emails := settingsClientEmails(t, ib.Id); len(emails) != 1 || emails[0] != "alice@dup" {
+		t.Fatalf("settings after duplicate re-add: expected exactly [alice@dup], got %v", emails)
+	}
+
+	bob := model.Client{ID: uuid.NewString(), Email: "bob@dup", SubID: "bob-sub-123456789", Enable: true}
+	if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{alice, bob})}); err != nil {
+		t.Fatalf("mixed add (one duplicate, one new): %v", err)
+	}
+	if emails := settingsClientEmails(t, ib.Id); len(emails) != 2 || emails[0] != "alice@dup" || emails[1] != "bob@dup" {
+		t.Fatalf("settings after mixed add: expected [alice@dup bob@dup], got %v", emails)
+	}
+}
+
+func TestDedupeSettingsClients(t *testing.T) {
+	dup := `{"clients": [` +
+		`{"id": "u1", "email": "a@x", "subId": "s1"},` +
+		`{"id": "u2", "email": "b@x", "subId": "s2"},` +
+		`{"id": "u1", "email": "a@x", "subId": "s1"},` +
+		`{"id": "u1", "email": "A@X", "subId": "s1"}]}`
+	out, changed := dedupeSettingsClients(dup)
+	if !changed {
+		t.Fatal("expected duplicates to be removed")
+	}
+	var parsed struct {
+		Clients []model.Client `json:"clients"`
+	}
+	mustUnmarshal(t, out, &parsed)
+	if len(parsed.Clients) != 2 || parsed.Clients[0].Email != "a@x" || parsed.Clients[1].Email != "b@x" {
+		t.Fatalf("expected first occurrences [a@x b@x], got %+v", parsed.Clients)
+	}
+
+	clean := `{"clients": [{"id": "u1", "email": "a@x"}, {"id": "u2", "email": "b@x"}]}`
+	if _, changed := dedupeSettingsClients(clean); changed {
+		t.Fatal("clean settings must not be rewritten")
+	}
+	if _, changed := dedupeSettingsClients(""); changed {
+		t.Fatal("empty settings must not be rewritten")
+	}
+	if _, changed := dedupeSettingsClients("{not json"); changed {
+		t.Fatal("invalid settings must not be rewritten")
+	}
+}

+ 4 - 1
internal/web/service/client_bulk.go

@@ -1067,9 +1067,12 @@ func (s *ClientService) bulkDelInboundClients(
 				push = false
 			}
 			if push {
+				// bulkDelInboundClients only runs for full client deletion
+				// (BulkDelete), so the node must drop its client record too,
+				// not just detach from this inbound (#5797).
 				pushFailed := false
 				for email := range foundEmails {
-					if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+					if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
 						logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 						pushFailed = true
 					}

+ 3 - 3
internal/web/service/client_crud.go

@@ -451,7 +451,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 		if existing.Email == "" {
 			continue
 		}
-		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false)
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false, true)
 		if delErr != nil {
 			// The client is already absent from this inbound (data drift or a
 			// retried delete). Skip it — deletion stays idempotent.
@@ -609,7 +609,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 	}
 	needRestart := false
 	for _, ibId := range inboundIds {
-		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false)
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
 		if delErr != nil {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 				continue
@@ -672,7 +672,7 @@ func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []
 		if existing.Email == "" {
 			continue
 		}
-		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true)
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true, false)
 		if delErr != nil {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 				continue

+ 66 - 0
internal/web/service/client_delete_node_full_test.go

@@ -0,0 +1,66 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/google/uuid"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A full client delete must reach the node as the full-delete RPC so the node
+// drops its own client record too — the detach RPC leaves an orphaned record
+// that keeps showing in the node's client list (#5797).
+func TestDelete_NodeClientDispatchesFullDeleteRPC(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+
+	clients := []model.Client{{ID: uuid.NewString(), Email: "full-del@x", Enable: true}}
+	nodeInbound(t, nodeID, 32001, clients)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	rec, err := svc.GetRecordByEmail(nil, "full-del@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+
+	if got := fake.deleteClient.Load(); got != 1 {
+		t.Fatalf("full delete dispatched %d DeleteClient RPCs, want 1", got)
+	}
+	if got := fake.deleteUser.Load(); got != 0 {
+		t.Fatalf("full delete dispatched %d DeleteUser (detach) RPCs, want 0", got)
+	}
+}
+
+// A plain detach must stay scoped to the one inbound via the detach RPC and
+// never escalate to the node-wide full delete.
+func TestDetach_NodeClientStaysOnDetachRPC(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+
+	clients := []model.Client{{ID: uuid.NewString(), Email: "detach-me@x", Enable: true}}
+	ib := nodeInbound(t, nodeID, 32002, clients)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	rec, err := svc.GetRecordByEmail(nil, "detach-me@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if _, err := svc.Detach(inboundSvc, rec.Id, []int{ib.Id}); err != nil {
+		t.Fatalf("Detach: %v", err)
+	}
+
+	if got := fake.deleteUser.Load(); got != 1 {
+		t.Fatalf("detach dispatched %d DeleteUser RPCs, want 1", got)
+	}
+	if got := fake.deleteClient.Load(); got != 0 {
+		t.Fatalf("detach dispatched %d DeleteClient RPCs, want 0", got)
+	}
+}

+ 50 - 8
internal/web/service/client_inbound_apply.go

@@ -319,12 +319,46 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 		return false, err
 	}
 
-	if oldInbound.Protocol == model.WireGuard {
-		existing, gcErr := inboundSvc.GetClients(oldInbound)
-		if gcErr != nil {
-			return false, gcErr
+	existingClients, err := inboundSvc.GetClients(oldInbound)
+	if err != nil {
+		return false, err
+	}
+
+	// A client already on this inbound is skipped instead of appended again:
+	// checkEmailsExistForClients exempts a matching subId so one identity can
+	// live on several inbounds, which let retried or raced adds duplicate the
+	// same email inside a single settings array (#5770). clients and
+	// interfaceClients are parsed from the same data.Settings array, so they
+	// stay index-aligned while filtering.
+	if len(existingClients) > 0 && len(clients) > 0 {
+		existingEmails := make(map[string]struct{}, len(existingClients))
+		for _, c := range existingClients {
+			if c.Email != "" {
+				existingEmails[strings.ToLower(c.Email)] = struct{}{}
+			}
+		}
+		keptClients := make([]model.Client, 0, len(clients))
+		keptWire := make([]any, 0, len(interfaceClients))
+		for i, c := range clients {
+			if c.Email != "" {
+				if _, dup := existingEmails[strings.ToLower(c.Email)]; dup {
+					continue
+				}
+			}
+			keptClients = append(keptClients, c)
+			if i < len(interfaceClients) {
+				keptWire = append(keptWire, interfaceClients[i])
+			}
 		}
-		if dErr := defaultWireguardClients(existing, clients, interfaceClients); dErr != nil {
+		if len(keptClients) == 0 {
+			return false, nil
+		}
+		clients = keptClients
+		interfaceClients = keptWire
+	}
+
+	if oldInbound.Protocol == model.WireGuard {
+		if dErr := defaultWireguardClients(existingClients, clients, interfaceClients); dErr != nil {
 			return false, dErr
 		}
 	}
@@ -831,7 +865,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	return needRestart, nil
 }
 
-func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string, keepTraffic bool) (bool, error) {
+func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string, keepTraffic bool, fullDelete bool) (bool, error) {
 	defer lockInbound(inboundId).Unlock()
 
 	oldInbound, err := inboundSvc.GetInbound(inboundId)
@@ -972,9 +1006,17 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		} else {
 			// Node inbound: propagate the delete regardless of the enable flag —
 			// the node's own DB still carries a disabled client and would
-			// resurrect it on the next snapshot otherwise.
+			// resurrect it on the next snapshot otherwise. A full client delete
+			// must remove the node's client record too, not just detach it from
+			// this inbound (#5797).
 			if push {
-				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+				var err1 error
+				if fullDelete {
+					err1 = rt.DeleteClient(context.Background(), email)
+				} else {
+					err1 = rt.DeleteUser(context.Background(), oldInbound, email)
+				}
+				if err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 				} else {
 					advancePushedInbound(rt, prevSettings, oldInbound)

+ 43 - 0
internal/web/service/client_locks.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"encoding/json"
+	"strings"
 	"sync"
 	"time"
 
@@ -141,6 +142,48 @@ func isClientEmailTombstoned(email string) bool {
 	return true
 }
 
+// dedupeSettingsClients collapses duplicate same-email client entries inside a
+// settings JSON blob, keeping the first occurrence. Node snapshots produced by
+// builds without the addInboundClient duplicate guard can carry duplicates
+// (#5770); adopting them verbatim would copy the duplication into the central
+// inbound. Returns the filtered JSON and whether anything was removed.
+func dedupeSettingsClients(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, _ := parsed["clients"].([]any)
+	if len(clients) < 2 {
+		return settings, false
+	}
+	seen := make(map[string]struct{}, len(clients))
+	kept := make([]any, 0, len(clients))
+	for _, c := range clients {
+		if cm, ok := c.(map[string]any); ok {
+			if email, _ := cm["email"].(string); email != "" {
+				key := strings.ToLower(email)
+				if _, dup := seen[key]; dup {
+					continue
+				}
+				seen[key] = struct{}{}
+			}
+		}
+		kept = append(kept, c)
+	}
+	if len(kept) == len(clients) {
+		return settings, false
+	}
+	parsed["clients"] = kept
+	b, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(b), true
+}
+
 // stripTombstonedClients drops just-deleted client entries from a node
 // snapshot's settings JSON so adopting a stale snapshot can't re-add them to
 // the central inbound while the delete tombstone is live. Returns the filtered

+ 1 - 1
internal/web/service/client_wireguard_crud_test.go

@@ -80,7 +80,7 @@ func TestWireGuardClientAddUpdateDeleteRoundTrip(t *testing.T) {
 		t.Fatalf("settings lost wg fields after metadata edit: %+v", listAfter)
 	}
 
-	if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, "alice@wg", false); err != nil {
+	if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, "alice@wg", false, false); err != nil {
 		t.Fatalf("DelInboundClientByEmail: %v", err)
 	}
 	final, err := svc.ListForInbound(nil, ib.Id)

+ 1 - 1
internal/web/service/del_shared_email_runtime_test.go

@@ -25,7 +25,7 @@ func TestDelInboundClientByEmail_SharedEmailStillRemovesFromRuntime(t *testing.T
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
 
-	if _, err := svc.DelInboundClientByEmail(inboundSvc, ibA.Id, "shared@x", false); err != nil {
+	if _, err := svc.DelInboundClientByEmail(inboundSvc, ibA.Id, "shared@x", false, false); err != nil {
 		t.Fatalf("DelInboundClientByEmail: %v", err)
 	}
 

+ 6 - 1
internal/web/service/inbound_node.go

@@ -534,6 +534,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if stripped, changed := stripTombstonedClients(adoptedSettings); changed {
 			adoptedSettings = stripped
 		}
+		if deduped, changed := dedupeSettingsClients(adoptedSettings); changed {
+			adoptedSettings = deduped
+		}
 
 		updates := map[string]any{}
 		if !dirty {
@@ -744,10 +747,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if err := tx.Exec(
 				fmt.Sprintf(
 					`UPDATE client_traffics
-					 SET up = up + ?, down = down + ?, enable = %s, total = ?,
+					 SET up = %s, down = %s, enable = %s, total = ?,
 					     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
 					     reset = ?, last_online = %s
 					 WHERE email = ?`,
+					database.ClampedAddExpr("up"),
+					database.ClampedAddExpr("down"),
 					enableExpr,
 					database.GreatestExpr("last_online", "?"),
 				),

+ 67 - 0
internal/web/service/inbound_tgbot_lookup_test.go

@@ -0,0 +1,67 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the
+// prefilter regressing into a formatting-sensitive string match (#5805): the
+// lookup must find clients whether inbounds.settings stores compact JSON
+// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as
+// written by the panel's MarshalIndent).
+func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const tgId int64 = 123456789
+	cases := []struct {
+		name     string
+		settings string
+		email    string
+		port     int
+	}{
+		{"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001},
+		{"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002},
+	}
+	for _, c := range cases {
+		inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings}
+		if err := db.Create(inbound).Error; err != nil {
+			t.Fatalf("create %s inbound: %v", c.name, err)
+		}
+		if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil {
+			t.Fatalf("create %s client_traffics: %v", c.name, err)
+		}
+	}
+
+	svc := InboundService{}
+	traffics, err := svc.GetClientTrafficTgBot(tgId)
+	if err != nil {
+		t.Fatalf("GetClientTrafficTgBot: %v", err)
+	}
+	got := make(map[string]bool, len(traffics))
+	for _, tr := range traffics {
+		got[tr.Email] = true
+	}
+	if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] {
+		t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got)
+	}
+
+	other, err := svc.GetClientTrafficTgBot(42)
+	if err != nil {
+		t.Fatalf("GetClientTrafficTgBot(42): %v", err)
+	}
+	if len(other) != 0 {
+		t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other))
+	}
+}

+ 23 - 8
internal/web/service/inbound_traffic.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"maps"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -86,8 +87,8 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
 		if traffic.IsInbound {
 			err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag).
 				Updates(map[string]any{
-					"up":   gorm.Expr("up + ?", traffic.Up),
-					"down": gorm.Expr("down + ?", traffic.Down),
+					"up":   gorm.Expr(database.ClampedAddExpr("up"), traffic.Up),
+					"down": gorm.Expr(database.ClampedAddExpr("down"), traffic.Down),
 				}).Error
 			if err != nil {
 				return err
@@ -152,7 +153,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		}
 		if err = tx.Exec(
 			fmt.Sprintf(
-				`UPDATE client_traffics SET up = up + ?, down = down + ?, last_online = %s WHERE email = ?`,
+				`UPDATE client_traffics SET up = %s, down = %s, last_online = %s WHERE email = ?`,
+				database.ClampedAddExpr("up"),
+				database.ClampedAddExpr("down"),
 				database.GreatestExpr("last_online", "?"),
 			),
 			t.Up, t.Down, now, ct.Email,
@@ -837,15 +840,27 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
 
 func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
 	db := database.GetDB()
-	var inbounds []*model.Inbound
 
-	// Retrieve inbounds where settings contain the given tgId
-	err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId": %d%%`, tgId)).Find(&inbounds).Error
-	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+	idQuery := fmt.Sprintf(
+		"SELECT DISTINCT inbounds.id %s WHERE %s = ?",
+		database.JSONClientsFromInbound(),
+		database.JSONFieldText("client.value", "tgId"),
+	)
+	var inboundIds []int
+	if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil {
 		logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
 		return nil, err
 	}
 
+	var inbounds []*model.Inbound
+	if len(inboundIds) > 0 {
+		err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
+			return nil, err
+		}
+	}
+
 	var emails []string
 	for _, inbound := range inbounds {
 		clients, err := s.GetClients(inbound)
@@ -866,7 +881,7 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 	traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
 	for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
 		var page []*xray.ClientTraffic
-		if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
+		if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
 			if errors.Is(err, gorm.ErrRecordNotFound) {
 				continue
 			}

+ 9 - 3
internal/web/service/node_bulk_dispatch_test.go

@@ -16,9 +16,10 @@ import (
 // fakeNodeRuntime is a runtime.Runtime stub that counts the per-client dispatch
 // calls so a test can assert a bulk op does NOT stream one RPC per client.
 type fakeNodeRuntime struct {
-	addClient  atomic.Int32
-	deleteUser atomic.Int32
-	updateUser atomic.Int32
+	addClient    atomic.Int32
+	deleteUser   atomic.Int32
+	deleteClient atomic.Int32
+	updateUser   atomic.Int32
 }
 
 func (f *fakeNodeRuntime) Name() string { return "fake-node" }
@@ -40,6 +41,11 @@ func (f *fakeNodeRuntime) DeleteUser(context.Context, *model.Inbound, string) er
 	return nil
 }
 
+func (f *fakeNodeRuntime) DeleteClient(context.Context, string) error {
+	f.deleteClient.Add(1)
+	return nil
+}
+
 func (f *fakeNodeRuntime) AddClient(context.Context, *model.Inbound, model.Client) error {
 	f.addClient.Add(1)
 	return nil

+ 1 - 1
internal/web/service/node_dirty_test.go

@@ -99,7 +99,7 @@ func TestDelInboundClientByEmail_DisabledNodeClientMarksDirty(t *testing.T) {
 
 	inboundSvc := &InboundService{}
 	clientSvc := &ClientService{}
-	if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false); err != nil {
+	if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false, false); err != nil {
 		t.Fatalf("DelInboundClientByEmail: %v", err)
 	}
 

+ 16 - 5
internal/web/service/outbound/outbound.go

@@ -42,6 +42,16 @@ func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []
 	return nil, false
 }
 
+// saturatingAdd caps counters at database.TrafficMax: unlike the SQL paths,
+// this read-modify-write add happens in Go, where an int64 overflow silently
+// wraps negative instead of erroring (#5762).
+func saturatingAdd(a, b int64) int64 {
+	if b > database.TrafficMax-a {
+		return database.TrafficMax
+	}
+	return a + b
+}
+
 func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
 	if len(traffics) == 0 {
 		return nil
@@ -61,9 +71,9 @@ func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traff
 			}
 
 			outbound.Tag = traffic.Tag
-			outbound.Up = outbound.Up + traffic.Up
-			outbound.Down = outbound.Down + traffic.Down
-			outbound.Total = outbound.Up + outbound.Down
+			outbound.Up = saturatingAdd(outbound.Up, traffic.Up)
+			outbound.Down = saturatingAdd(outbound.Down, traffic.Down)
+			outbound.Total = saturatingAdd(outbound.Up, outbound.Down)
 
 			err = tx.Save(&outbound).Error
 			if err != nil {
@@ -111,8 +121,9 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
 
 // TestOutboundResult represents the result of testing an outbound.
 // Delay is in milliseconds. Endpoints is only populated for TCP-mode
-// probes; HTTP mode reports the time of a real HTTP request routed
-// through the outbound, with an optional timing breakdown.
+// probes; HTTP mode reports the round-trip of a real HTTP request on an
+// established connection through the outbound (the cold first request
+// supplies the timing breakdown).
 type TestOutboundResult struct {
 	Tag     string `json:"tag,omitempty"`
 	Success bool   `json:"success"`

+ 55 - 14
internal/web/service/outbound/probe_http.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"io/fs"
 	"net"
 	"net/http"
@@ -28,11 +29,18 @@ import (
 // client-side (instead of polling xray's observatory) returns the moment the
 // response lands, yields the actual HTTP status, and allows an httptrace
 // timing breakdown — while the shared process keeps "Test All" at one xray
-// spawn per batch instead of one per outbound.
+// spawn per batch instead of one per outbound. The reported delay comes from
+// a second request on the kept-alive connection, so it reflects the tunnel's
+// real per-request round-trip rather than the stacked SOCKS/proxy/TLS
+// handshakes of connection establishment.
 
 const (
-	// httpProbeTimeout bounds one probe request end-to-end.
+	// httpProbeTimeout bounds each probe request end-to-end (a probe makes
+	// two: a cold one for the breakdown, a warm one for the delay).
 	httpProbeTimeout = 10 * time.Second
+	// probeDrainLimit caps how much response body a probe reads back to keep
+	// the connection reusable for the warm request.
+	probeDrainLimit = 256 << 10
 	// httpProbeConcurrency caps parallel probe requests within a batch —
 	// enough to keep a batch fast, low enough not to spike CPU with TLS
 	// handshakes on small VPSes.
@@ -427,18 +435,22 @@ func outboundsContainTag(outbounds []any, tag string) bool {
 	return false
 }
 
-// probeThroughSocks issues one timed GET through the local SOCKS inbound at
-// the given port and fills result. Any HTTP response — including 4xx/5xx and
-// unfollowed redirects — counts as reachable; only transport-level failures
-// (refused, reset, timeout, proxy errors) are failures. Delay is request
-// start → response headers; the test URL's hostname is resolved by xray
-// (Go's SOCKS5 client sends the domain to the proxy), so DNS goes through
-// the outbound too.
+// probeThroughSocks probes the local SOCKS inbound at the given port and
+// fills result. A first, cold GET proves reachability and carries the
+// httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed
+// redirects — counts as reachable; only transport-level failures (refused,
+// reset, timeout, proxy errors) are failures. Delay is then re-measured on a
+// warm request over the kept-alive connection — the real round-trip through
+// the established tunnel — falling back to the cold total if the warm request
+// fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
+// sends the domain to the proxy), so DNS goes through the outbound too.
 func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
 	proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
 	tr := &http.Transport{
-		Proxy:             http.ProxyURL(proxyURL),
-		DisableKeepAlives: true,
+		Proxy:               http.ProxyURL(proxyURL),
+		MaxIdleConns:        1,
+		MaxIdleConnsPerHost: 1,
+		IdleConnTimeout:     timeout,
 	}
 	defer tr.CloseIdleConnections()
 	client := &http.Client{
@@ -496,15 +508,14 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
 		return
 	}
 	resp, err := client.Do(req)
-	delay := time.Since(start).Milliseconds()
+	coldDelay := time.Since(start).Milliseconds()
 	if err != nil {
 		result.Error = err.Error()
 		return
 	}
-	resp.Body.Close()
+	drainAndClose(resp)
 
 	result.Success = true
-	result.Delay = max(delay, 1)
 	result.HTTPStatus = resp.StatusCode
 	if connDone {
 		result.ConnectMs = max(connDur.Milliseconds(), 1)
@@ -515,6 +526,36 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
 	if gotFirstRB {
 		result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
 	}
+
+	delay := coldDelay
+	if warmDelay, ok := timedWarmGet(client, testURL); ok {
+		delay = warmDelay
+	}
+	result.Delay = max(delay, 1)
+}
+
+// timedWarmGet re-issues the probe request over the transport's kept-alive
+// connection and returns its duration — the tunnel's per-request round-trip.
+func timedWarmGet(client *http.Client, testURL string) (int64, bool) {
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
+	if err != nil {
+		return 0, false
+	}
+	start := time.Now()
+	resp, err := client.Do(req)
+	delay := time.Since(start).Milliseconds()
+	if err != nil {
+		return 0, false
+	}
+	drainAndClose(resp)
+	return delay, true
+}
+
+// drainAndClose consumes the body (bounded by probeDrainLimit) so the
+// connection returns to the keep-alive pool for the warm request.
+func drainAndClose(resp *http.Response) {
+	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeDrainLimit))
+	resp.Body.Close()
 }
 
 // reserveLoopbackPorts grabs n free loopback ports and keeps the listeners

+ 19 - 0
internal/web/service/outbound/probe_http_test.go

@@ -11,6 +11,7 @@ import (
 	"net/http/httptest"
 	"strconv"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -399,7 +400,12 @@ func TestTestOutboundsTCPLane(t *testing.T) {
 }
 
 func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
+	var mu sync.Mutex
+	requestsPerConn := make(map[string]int)
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		mu.Lock()
+		requestsPerConn[r.RemoteAddr]++
+		mu.Unlock()
 		w.WriteHeader(http.StatusNoContent)
 	}))
 	defer srv.Close()
@@ -443,6 +449,19 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 	if proc.IsRunning() {
 		t.Error("temp process not stopped after batch")
 	}
+
+	mu.Lock()
+	defer mu.Unlock()
+	totalRequests := 0
+	for addr, n := range requestsPerConn {
+		totalRequests += n
+		if n != 2 {
+			t.Errorf("connection %s served %d requests, want 2 (warm delay request must reuse the cold request's connection)", addr, n)
+		}
+	}
+	if totalRequests != 4 {
+		t.Errorf("test URL served %d requests, want 4 (cold + warm per probe)", totalRequests)
+	}
 }
 
 func TestProbeThroughSocksTransportFailure(t *testing.T) {

+ 1 - 1
internal/web/service/sync_scale_postgres_test.go

@@ -201,7 +201,7 @@ func TestAddDelClientPostgresScale(t *testing.T) {
 
 			delEmail := clients[n/2].Email
 			start = time.Now()
-			if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, delEmail, false); err != nil {
+			if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, delEmail, false, false); err != nil {
 				t.Fatalf("DelInboundClientByEmail: %v", err)
 			}
 			delDur := time.Since(start)

+ 7 - 4
update.sh

@@ -193,7 +193,7 @@ install_base() {
             fi
             ;;
         arch | manjaro | parch)
-            pacman -Syu > /dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1
+            pacman -Sy --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         opensuse-tumbleweed | opensuse-leap)
             zypper refresh > /dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl > /dev/null 2>&1
@@ -1023,6 +1023,7 @@ update_x-ui() {
         rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1
         echo -e "${green}Removing old xray version...${plain}"
         rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1
+        rm ${xui_folder}/bin/xray-linux-arm -f > /dev/null 2>&1
         echo -e "${green}Removing old README and LICENSE file...${plain}"
         rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
         rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1
@@ -1044,10 +1045,12 @@ update_x-ui() {
     fi
     chmod +x x-ui > /dev/null 2>&1
 
-    # Check the system's architecture and rename the file accordingly
+    # Check the system's architecture and rename the file accordingly.
+    # The panel binary maps GOARCH=arm to "arm32" (internal/xray/process.go),
+    # so the Xray binary must be named xray-linux-arm32; mtg keeps plain "arm".
     if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
-        mv bin/xray-linux-$(arch) bin/xray-linux-arm > /dev/null 2>&1
-        chmod +x bin/xray-linux-arm > /dev/null 2>&1
+        mv bin/xray-linux-$(arch) bin/xray-linux-arm32 > /dev/null 2>&1
+        chmod +x bin/xray-linux-arm32 > /dev/null 2>&1
     fi
 
     chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1

+ 41 - 2
x-ui.sh

@@ -2286,6 +2286,11 @@ setup_fail2ban_iplimit() {
                 apt-get update && apt-get install fail2ban nftables -y
                 ;;
             fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
+                if [[ "${release}" != "fedora" ]] && ! dnf repolist enabled 2> /dev/null | grep -qiw epel; then
+                    dnf install -y epel-release \
+                        || dnf install -y "https://dl.fedoraproject.org/pub/epel/epel-release-latest-$(rpm -E %rhel).noarch.rpm" \
+                        || echo -e "${yellow}Could not enable the EPEL repository; fail2ban is only available from EPEL on this distro.${plain}"
+                fi
                 dnf makecache -y && dnf -y install fail2ban nftables
                 ;;
             centos)
@@ -2297,7 +2302,7 @@ setup_fail2ban_iplimit() {
                 fi
                 ;;
             arch | manjaro | parch)
-                pacman -Syu --noconfirm fail2ban nftables
+                pacman -Sy --noconfirm fail2ban nftables
                 ;;
             alpine)
                 apk add fail2ban nftables
@@ -2846,6 +2851,37 @@ purge_postgresql() {
     LOGI "PostgreSQL has been purged."
 }
 
+# RHEL-family initdb writes pg_hba.conf host rules with ident auth, which
+# compares the OS username against the Postgres role and always rejects the
+# randomly generated panel role over TCP (#5806). Prepend password-auth rules
+# scoped to the panel database; first match wins, and md5 also accepts
+# scram-sha-256-stored verifiers, so this works on every supported distro.
+# Mirrors pg_ensure_hba_password_auth() from install.sh.
+pg_ensure_hba_password_auth() {
+    local pg_db="$1"
+    local hba_file
+    hba_file=$(sudo -u postgres psql -tAc 'SHOW hba_file' 2> /dev/null | tr -d '[:space:]')
+    [[ -n "${hba_file}" && -f "${hba_file}" ]] || return 0
+    grep -Eq "^host[[:space:]]+${pg_db}[[:space:]]" "${hba_file}" && return 0
+    local tmp
+    tmp=$(mktemp) || return 1
+    {
+        echo "# Added by 3x-ui: allow password logins for the panel database."
+        echo "host    ${pg_db}    all    127.0.0.1/32    md5"
+        echo "host    ${pg_db}    all    ::1/128         md5"
+        cat "${hba_file}"
+    } > "${tmp}" || {
+        rm -f "${tmp}"
+        return 1
+    }
+    cat "${tmp}" > "${hba_file}" || {
+        rm -f "${tmp}"
+        return 1
+    }
+    rm -f "${tmp}"
+    sudo -u postgres psql -tAc 'SELECT pg_reload_conf()' > /dev/null 2>&1 || true
+}
+
 # Installs a local PostgreSQL server and creates a dedicated xui user/database.
 # Progress goes to stderr; on success the connection DSN is printed to stdout so
 # callers can capture it. Mirrors install_postgres_local() from install.sh, so the
@@ -2874,7 +2910,7 @@ pg_install_local() {
             [[ -d /var/lib/pgsql/data && -f /var/lib/pgsql/data/PG_VERSION ]] || postgresql-setup --initdb >&2 || return 1
             ;;
         arch | manjaro | parch)
-            pacman -Syu --noconfirm postgresql >&2 || return 1
+            pacman -Sy --noconfirm postgresql >&2 || return 1
             if [[ ! -f /var/lib/postgres/data/PG_VERSION ]]; then
                 sudo -u postgres initdb -D /var/lib/postgres/data >&2 || return 1
             fi
@@ -2930,6 +2966,9 @@ pg_install_local() {
 
     sudo -u postgres psql -c "ALTER USER \"${pg_user}\" WITH PASSWORD '${pg_pass}';" >&2 || return 1
 
+    pg_ensure_hba_password_auth "${pg_db}" \
+        || echo -e "${yellow}Warning: could not update pg_hba.conf; PostgreSQL may reject the panel's TCP login (ident auth).${plain}" >&2
+
     local pg_pass_enc
     pg_pass_enc=$(printf '%s' "${pg_pass}" | sed -e 's/%/%25/g' -e 's/:/%3A/g' -e 's/@/%40/g' -e 's|/|%2F|g' -e 's/?/%3F/g' -e 's/#/%23/g')