15 Commity 2a03844566 ... 588ea86298

Autor SHA1 Wiadomość Data
  MHSanaei 588ea86298 fix(hysteria): use pinSHA256 for pinned cert and emit ech in share links 3 godzin temu
  MHSanaei 7f8c79675f fix(sub): source Userinfo total/expiry from client config in multi-node (#4645) 4 godzin temu
  MHSanaei 80173b1b1d fix(db): make password-hash migration idempotent to prevent lock-out (#4612) 5 godzin temu
  MHSanaei 6ae1b38607 fix(outbound): add None option to uTLS fingerprint in TLS form (#4760) 6 godzin temu
  MHSanaei 803e010921 fix(outbound): carry ALPN, fingerprint and UDP mask when importing a Hysteria2 link (#4760) 6 godzin temu
  MHSanaei b6641439d4 fix(sockopt): rename interfaceName to interface so xray honors it 7 godzin temu
  MHSanaei d29a17d333 fix(sub): ensure unique Clash proxy names (#4641) 7 godzin temu
  MHSanaei 39b716409a fix(settings): enforce trafficDiff max of 100 in UI (#4769) 8 godzin temu
  MHSanaei 13c04bb982 fix(outbound): fill encryption and pqv when importing VLESS link 8 godzin temu
  MHSanaei 28330e60d8 fix(docker): grant NET_ADMIN/NET_RAW so fail2ban IP-limit bans apply 8 godzin temu
  MHSanaei 72121784fe test(iplimit): align ban-policy tests with last-IP-wins (#4699) 8 godzin temu
  ALOKY 16edb037e7 Fix IP limit enforcement and clarify related comments (#4699) 9 godzin temu
  xiaoxiyao 2b7c1eeb6a fix(sub): Add Clash subscription profile filename header (#4743) 9 godzin temu
  fgsfds 6b2243a40f chore(ui): remove cards jump on hover (#4755) 9 godzin temu
  ckun52880 f9aa363a63 Replace static label with translation for downlink stats (#4762) 9 godzin temu

+ 6 - 0
README.md

@@ -62,6 +62,12 @@ The default `docker compose up -d` keeps using SQLite. To run with the bundled P
 docker compose --profile postgres up -d
 ```
 
+The image bundles Fail2ban (enabled by default) to enforce per-client **IP limits**. Fail2ban bans offenders with `iptables`, which requires the `NET_ADMIN` capability. `docker-compose.yml` already grants it via `cap_add`; if you start the container with `docker run` instead, add the capabilities yourself, otherwise bans are logged but never applied:
+
+```bash
+docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
+```
+
 ## A Special Thanks to
 
 - [alireza0](https://github.com/alireza0/)

+ 3 - 0
database/db.go

@@ -203,6 +203,9 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 
 		for _, user := range users {
+			if crypto.IsHashed(user.Password) {
+				continue
+			}
 			hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
 			if err != nil {
 				log.Printf("Error hashing password for user '%s': %v", user.Username, err)

+ 7 - 0
docker-compose.yml

@@ -5,6 +5,13 @@ services:
       dockerfile: ./Dockerfile
     container_name: 3xui_app
     # hostname: yourhostname <- optional
+    # The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
+    # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
+    # and shown in fail2ban status but never actually applied. NET_RAW covers
+    # ip6tables. If you disable Fail2ban, you can drop cap_add.
+    cap_add:
+      - NET_ADMIN
+      - NET_RAW
     volumes:
       - $PWD/db/:/etc/x-ui/
       - $PWD/cert/:/root/cert/

+ 3 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -610,6 +610,9 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
   if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
   if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
   if (tls.serverName.length > 0) params.set('sni', tls.serverName);
+  if (tls.settings.pinnedPeerCertSha256.length > 0) {
+    params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.join(','));
+  }
 
   const udpMasks = stream.finalmask?.udp;
   if (Array.isArray(udpMasks)) {

+ 1 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -123,7 +123,7 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
     port,
     id,
     flow,
-    encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
+    encryption: encryption || 'none',
     reverseTag,
     reverseSniffing,
     testpre: asNumber(raw.testpre, 0),

+ 6 - 3
frontend/src/lib/xray/outbound-link-parser.ts

@@ -210,6 +210,7 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
     reality.publicKey = params.get('pbk') ?? '';
     reality.shortId = params.get('sid') ?? '';
     reality.spiderX = params.get('spx') ?? '';
+    reality.mldsa65Verify = params.get('pqv') ?? '';
   }
 }
 
@@ -403,6 +404,7 @@ export function parseHysteria2Link(link: string): Raw | null {
   const address = url.hostname;
   const port = Number(url.port) || 443;
   const params = url.searchParams;
+  const alpn = params.get('alpn');
   const stream: Raw = {
     network: 'hysteria',
     security: 'tls',
@@ -411,13 +413,14 @@ export function parseHysteria2Link(link: string): Raw | null {
     },
     tlsSettings: {
       serverName: params.get('sni') ?? '',
-      alpn: ['h3'],
-      fingerprint: '',
-      echConfigList: '',
+      alpn: alpn ? alpn.split(',') : ['h3'],
+      fingerprint: params.get('fp') ?? '',
+      echConfigList: params.get('ech') ?? '',
       verifyPeerCertByName: '',
       pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
     },
   };
+  applyFinalMaskParam(stream, params);
   return {
     protocol: 'hysteria',
     tag: decodeRemark(url),

+ 1 - 1
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -130,7 +130,7 @@ export default function SockoptForm({
                   <Input />
                 </Form.Item>
                 <Form.Item
-                  name={['streamSettings', 'sockopt', 'interfaceName']}
+                  name={['streamSettings', 'sockopt', 'interface']}
                   label={t('pages.inbounds.info.interfaceName')}
                 >
                   <Input />

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

@@ -205,7 +205,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
                 onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
-              <InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }}
+              <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
                 onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
             </SettingListItem>
           </>

+ 1 - 1
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -168,7 +168,7 @@ export default function BasicsTab({
             ['statsInboundUplink', t('pages.xray.statsInboundUplink')],
             ['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
             ['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
-            ['statsOutboundDownlink', 'Outbound downlink stats'],
+            ['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
           ].map(([field, label]) => (
             <SettingListItem
               key={field}

+ 1 - 1
frontend/src/pages/xray/outbounds/security/tls.tsx

@@ -20,7 +20,7 @@ export default function TlsForm() {
         <Select
           allowClear
           placeholder={t('none')}
-          options={UTLS_OPTIONS}
+          options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
         />
       </Form.Item>
       <Form.Item

+ 1 - 1
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -89,7 +89,7 @@ export default function SockoptForm({ form }: { form: FormInstance<OutboundFormV
                 </Form.Item>
                 <Form.Item
                   label={t('pages.xray.outboundForm.interface')}
-                  name={['streamSettings', 'sockopt', 'interfaceName']}
+                  name={['streamSettings', 'sockopt', 'interface']}
                 >
                   <Input />
                 </Form.Item>

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

@@ -65,7 +65,7 @@ export const SockoptStreamSettingsSchema = z.object({
   tcpcongestion: TcpCongestionSchema.default('bbr'),
   V6Only: z.boolean().default(false),
   tcpWindowClamp: z.number().int().min(0).default(600),
-  interfaceName: z.string().default(''),
+  interface: z.string().default(''),
   trustedXForwardedFor: z.array(z.string()).default([]),
   addressPortStrategy: AddressPortStrategySchema.default('none'),
   happyEyeballs: HappyEyeballsSchema.optional(),

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

@@ -16,7 +16,7 @@ export const AllSettingSchema = z.object({
   panelProxy: z.string().optional(),
   pageSize: z.number().int().min(1).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),
-  trafficDiff: nonNegativeInt.optional(),
+  trafficDiff: nonNegativeInt.max(100).optional(),
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   tgBotEnable: z.boolean().optional(),

+ 0 - 1
frontend/src/styles/page-cards.css

@@ -45,7 +45,6 @@
 .nodes-page .ant-card.ant-card-hoverable:hover,
 .groups-page .ant-card.ant-card-hoverable:hover,
 .api-docs-page .ant-card.ant-card-hoverable:hover {
-  transform: translateY(-2px);
   box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
 }
 

+ 4 - 4
frontend/src/test/__snapshots__/sockopt.test.ts.snap

@@ -8,7 +8,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
   "customSockopt": [],
   "dialerProxy": "",
   "domainStrategy": "AsIs",
-  "interfaceName": "",
+  "interface": "",
   "mark": 0,
   "penetrate": false,
   "tcpFastOpen": false,
@@ -32,7 +32,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses full byte-stably 1`] = `
   "customSockopt": [],
   "dialerProxy": "out-proxy-tag",
   "domainStrategy": "UseIP",
-  "interfaceName": "eth0",
+  "interface": "eth0",
   "mark": 100,
   "penetrate": false,
   "tcpFastOpen": true,
@@ -59,7 +59,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tcp-tuning byte-stably 1`
   "customSockopt": [],
   "dialerProxy": "",
   "domainStrategy": "AsIs",
-  "interfaceName": "",
+  "interface": "",
   "mark": 0,
   "penetrate": false,
   "tcpFastOpen": true,
@@ -83,7 +83,7 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
   "customSockopt": [],
   "dialerProxy": "",
   "domainStrategy": "ForceIPv4",
-  "interfaceName": "",
+  "interface": "",
   "mark": 255,
   "penetrate": true,
   "tcpFastOpen": false,

+ 1 - 1
frontend/src/test/golden/fixtures/sockopt/full.json

@@ -14,6 +14,6 @@
   "tcpcongestion": "cubic",
   "V6Only": false,
   "tcpWindowClamp": 600,
-  "interfaceName": "eth0",
+  "interface": "eth0",
   "trustedXForwardedFor": ["10.0.0.0/8", "192.168.0.0/16"]
 }

+ 19 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -74,6 +74,25 @@ describe('outbound-form-adapter: round-trip', () => {
     });
   });
 
+  it('vless preserves a non-none encryption value (post-quantum)', () => {
+    const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
+    const wire = {
+      protocol: 'vless',
+      settings: {
+        address: 'srv',
+        port: 443,
+        id: '11111111-2222-4333-8444-555555555555',
+        flow: '',
+        encryption: enc,
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'vless') {
+      expect(form.settings.encryption).toBe(enc);
+    }
+    expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(enc);
+  });
+
   it('vless emits reverse + sniffing when reverseTag is set', () => {
     const wire = {
       protocol: 'vless',

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

@@ -173,6 +173,23 @@ describe('parseVlessLink', () => {
     expect(reality.shortId).toBe('abcd');
     expect(reality.serverName).toBe('cloudflare.com');
   });
+
+  it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
+    const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
+    const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
+    const link
+      = 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121'
+      + `?encryption=${enc}&pqv=${pqv}`
+      + '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com'
+      + '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp'
+      + '#giqssbgmo9';
+    const out = parseVlessLink(link);
+    const settings = out?.settings as { encryption: string };
+    expect(settings.encryption).toBe(enc);
+    const reality = (out?.streamSettings as Record<string, unknown>).realitySettings as Record<string, unknown>;
+    expect(reality.mldsa65Verify).toBe(pqv);
+    expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
+  });
 });
 
 describe('parseTrojanLink', () => {
@@ -250,6 +267,32 @@ describe('parseHysteria2Link', () => {
     const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
     expect(out?.protocol).toBe('hysteria');
   });
+
+  it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
+    const link = 'hysteria2://[email protected]:8443?'
+      + 'alpn=h2%2Chttp%2F1.1&'
+      + 'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&'
+      + 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org'
+      + '#hy2-ej596ty350qs';
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const stream = out!.streamSettings as Record<string, unknown>;
+    const tls = stream.tlsSettings as Record<string, unknown>;
+    expect(tls.alpn).toEqual(['h2', 'http/1.1']);
+    expect(tls.fingerprint).toBe('chrome');
+    expect(tls.serverName).toBe('news.domain.org');
+    const finalmask = stream.finalmask as Record<string, unknown>;
+    expect(finalmask).toBeDefined();
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp[0].type).toBe('salamander');
+    expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
+  });
+
+  it('defaults alpn to h3 when the link omits it', () => {
+    const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
+    const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
+    expect(tls.alpn).toEqual(['h3']);
+  });
 });
 
 describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {

+ 34 - 0
sub/subClashService.go

@@ -60,6 +60,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		return "", "", nil
 	}
 
+	ensureUniqueProxyNames(proxies)
+
 	emails := make([]string, 0, len(seenEmails))
 	for e := range seenEmails {
 		emails = append(emails, e)
@@ -93,6 +95,38 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	return string(finalYAML), header, nil
 }
 
+// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique:
+// mihomo rejects the whole config on a duplicate name (the empty string
+// genRemark returns for a remark-less inbound counts), vanishing the Clash
+// profile on refresh. See issue #4641.
+func ensureUniqueProxyNames(proxies []map[string]any) {
+	seen := make(map[string]struct{}, len(proxies))
+	for i, proxy := range proxies {
+		base, _ := proxy["name"].(string)
+		if base == "" {
+			base = fallbackProxyName(proxy, i)
+		}
+		name := base
+		for n := 2; ; n++ {
+			if _, dup := seen[name]; !dup {
+				break
+			}
+			name = fmt.Sprintf("%s-%d", base, n)
+		}
+		seen[name] = struct{}{}
+		proxy["name"] = name
+	}
+}
+
+func fallbackProxyName(proxy map[string]any, idx int) string {
+	typ, _ := proxy["type"].(string)
+	server, _ := proxy["server"].(string)
+	if typ != "" && server != "" {
+		return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"])
+	}
+	return fmt.Sprintf("proxy-%d", idx+1)
+}
+
 func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
 	stream := s.streamData(inbound.StreamSettings)
 	// For node-managed inbounds the Clash proxy "server" must be the

+ 34 - 0
sub/subClashService_test.go

@@ -5,6 +5,40 @@ import (
 	"testing"
 )
 
+func TestEnsureUniqueProxyNames(t *testing.T) {
+	proxies := []map[string]any{
+		{"name": "", "type": "vless", "server": "a.com", "port": 443},
+		{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
+		{"name": "node"},
+		{"name": "node"},
+		{"name": ""},
+	}
+
+	ensureUniqueProxyNames(proxies)
+
+	seen := map[string]bool{}
+	for i, p := range proxies {
+		name, _ := p["name"].(string)
+		if name == "" {
+			t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
+		}
+		if seen[name] {
+			t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
+		}
+		seen[name] = true
+	}
+
+	if got := proxies[0]["name"]; got != "vless-a.com-443" {
+		t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
+	}
+	if proxies[2]["name"] == proxies[3]["name"] {
+		t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
+	}
+	if got := proxies[4]["name"]; got != "proxy-5" {
+		t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
+	}
+}
+
 func TestApplyTransport_XHTTP(t *testing.T) {
 	svc := &SubClashService{}
 	proxy := map[string]any{}

+ 4 - 0
sub/subController.go

@@ -277,6 +277,10 @@ func (a *SUBController) subClashs(c *gin.Context) {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		}
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+		if a.subTitle != "" {
+			// Clash clients commonly use Content-Disposition to choose the imported profile name.
+			c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, a.subTitle))
+		}
 		c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
 	}
 }

+ 41 - 11
sub/subService.go

@@ -158,34 +158,61 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
 	if len(emails) == 0 {
 		return agg, 0
 	}
+	db := database.GetDB()
 	var rows []xray.ClientTraffic
-	if err := database.GetDB().
+	if err := db.
 		Model(&xray.ClientTraffic{}).
 		Where("email IN ?", emails).
 		Find(&rows).Error; err != nil {
 		logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err)
 		return agg, 0
 	}
+
+	// total/expiry are configured limits owned by the clients table, not the
+	// runtime traffic rows. In a multi-node setup the node snapshot can reset
+	// client_traffics.total/expiry_time to 0, so fall back to the clients
+	// table to keep the Subscription-Userinfo header in sync with the UI (#4645).
+	limits := make(map[string][2]int64, len(emails))
+	var records []model.ClientRecord
+	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Find(&records).Error; err != nil {
+		logger.Warning("SubService - AggregateTrafficByEmails: load client limits:", err)
+	} else {
+		for _, r := range records {
+			limits[r.Email] = [2]int64{r.TotalGB, r.ExpiryTime}
+		}
+	}
+
 	now := time.Now().UnixMilli()
-	for i, ct := range rows {
+	first := true
+	for _, ct := range rows {
 		if ct.LastOnline > lastOnline {
 			lastOnline = ct.LastOnline
 		}
-		if i == 0 {
+		total, expiry := ct.Total, ct.ExpiryTime
+		if lim, ok := limits[ct.Email]; ok {
+			if total == 0 {
+				total = lim[0]
+			}
+			if expiry == 0 {
+				expiry = lim[1]
+			}
+		}
+		if first {
 			agg.Up = ct.Up
 			agg.Down = ct.Down
-			agg.Total = ct.Total
-			agg.ExpiryTime = subscriptionExpiryFromClient(now, ct.ExpiryTime)
+			agg.Total = total
+			agg.ExpiryTime = subscriptionExpiryFromClient(now, expiry)
+			first = false
 			continue
 		}
 		agg.Up += ct.Up
 		agg.Down += ct.Down
-		if agg.Total == 0 || ct.Total == 0 {
+		if agg.Total == 0 || total == 0 {
 			agg.Total = 0
 		} else {
-			agg.Total += ct.Total
+			agg.Total += total
 		}
-		normalized := subscriptionExpiryFromClient(now, ct.ExpiryTime)
+		normalized := subscriptionExpiryFromClient(now, expiry)
 		if normalized != agg.ExpiryTime {
 			agg.ExpiryTime = 0
 		}
@@ -576,11 +603,14 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
 			params["fp"], _ = fpValue.(string)
 		}
-		if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
-			if insecure.(bool) {
-				params["insecure"] = "1"
+		if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
+			if ech, _ := echValue.(string); ech != "" {
+				params["ech"] = ech
 			}
 		}
+		if pins, ok := pinnedSha256List(tlsSettings); ok {
+			params["pinSHA256"] = strings.Join(pins, ",")
+		}
 	}
 
 	// salamander obfs (Hysteria2). The panel-side link generator already

+ 56 - 0
sub/subService_userinfo_test.go

@@ -0,0 +1,56 @@
+package sub
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func TestAggregateTrafficByEmails_FallsBackToClientLimits(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() })
+
+	const email = "[email protected]"
+	const totalBytes = int64(300) * 1024 * 1024 * 1024
+	const expiry = int64(1893456000000)
+
+	db := database.GetDB()
+	if err := db.Create(&model.ClientRecord{
+		Email:      email,
+		TotalGB:    totalBytes,
+		ExpiryTime: expiry,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		Email:      email,
+		Up:         111,
+		Down:       222,
+		Total:      0,
+		ExpiryTime: 0,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatalf("seed client traffic: %v", err)
+	}
+
+	var s SubService
+	agg, _ := s.AggregateTrafficByEmails([]string{email})
+
+	if agg.Up != 111 || agg.Down != 222 {
+		t.Errorf("usage = up %d/down %d, want 111/222", agg.Up, agg.Down)
+	}
+	if agg.Total != totalBytes {
+		t.Errorf("total = %d, want %d (fallback to clients table)", agg.Total, totalBytes)
+	}
+	if agg.ExpiryTime != expiry {
+		t.Errorf("expiry = %d, want %d (fallback to clients table)", agg.ExpiryTime, expiry)
+	}
+}

+ 5 - 0
util/crypto/crypto.go

@@ -15,3 +15,8 @@ func HashPasswordAsBcrypt(password string) (string, error) {
 func CheckPasswordHash(hash, password string) bool {
 	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
 }
+
+func IsHashed(s string) bool {
+	_, err := bcrypt.Cost([]byte(s))
+	return err == nil
+}

+ 13 - 14
web/job/check_client_ip_job.go

@@ -41,8 +41,8 @@ const defaultXrayAPIPort = 62789
 //
 // Without this eviction, an IP that connected once and then went away
 // keeps sitting in the table with its old timestamp. Because the
-// excess-IP selector sorts ascending ("oldest wins, newest loses") to
-// protect the original/current connections, that stale entry keeps
+// excess-IP selector sorts ascending ("newest wins, oldest loses") to
+// protect the most recent connections, that stale entry keeps
 // occupying a slot and the IP that is *actually* currently using the
 // config gets classified as "new excess" and banned by fail2ban on
 // every single run — producing the continuous ban loop from #4077.
@@ -255,15 +255,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 //
 // only live ips count toward the per-client limit. historical ones stay
 // in the db so the panel keeps showing them, but they must not take a
-// protected slot. the 30min cutoff alone isn't tight enough: an ip that
-// stopped connecting a few minutes ago still looks fresh to
-// mergeClientIps, and since the over-limit picker sorts ascending and
-// keeps the oldest, those idle entries used to win the slot while the
-// ip actually connecting got classified as excess and sent to fail2ban
-// every tick. see #4077 / #4091.
+// live slot or get re-banned. the 30min cutoff alone isn't tight enough:
+// an ip that stopped connecting a few minutes ago still looks fresh to
+// mergeClientIps, and without this split it would keep triggering
+// fail2ban even though it isn't currently connected. see #4077 / #4091.
 //
-// live is sorted ascending so the "protect original, ban newcomer"
-// rule still holds when several ips are really connecting at once.
+// live is sorted ascending by timestamp (oldest → newest), so we keep
+// the most recent entries at the end of the slice (last IP wins).
 func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
 	live = make([]IPWithTimestamp, 0, len(observedThisScan))
 	historical = make([]IPWithTimestamp, 0, len(ipMap))
@@ -408,9 +406,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	if len(liveIps) > limitIp {
 		shouldCleanLog = true
 
-		// protect the oldest live ip, ban newcomers.
-		keptLive = liveIps[:limitIp]
-		bannedLive := liveIps[limitIp:]
+		// keep the newest live ips, ban older ones.
+		cutoff := len(liveIps) - limitIp
+		keptLive = liveIps[cutoff:]
+		bannedLive := liveIps[:cutoff]
 
 		// Open log file only when a ban entry needs to be written.
 		// Use a local logger to avoid mutating the global log.* state,
@@ -456,7 +455,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	}
 
 	if len(j.disAllowedIps) > 0 {
-		logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
+		logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
 	}
 
 	return shouldCleanLog

+ 11 - 10
web/job/check_client_ip_job_integration_test.go

@@ -179,7 +179,8 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 }
 
 // opposite invariant: when several ips are actually live and exceed
-// the limit, the newcomer still gets banned.
+// the limit, the oldest connection is dropped and the most recent one
+// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
 func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	setupIntegrationDB(t)
 
@@ -193,8 +194,8 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 
 	j := NewCheckClientIpJob()
 	// both live, limit=1. use distinct timestamps so sort-by-timestamp
-	// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
-	// joined later and must get banned.
+	// is deterministic: 10.1.0.1 is the original (older) and must get
+	// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
 	live := []IPWithTimestamp{
 		{IP: "10.1.0.1", Timestamp: now - 5},
 		{IP: "192.0.2.9", Timestamp: now},
@@ -205,16 +206,16 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
 	}
-	if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
-		t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
+	if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
+		t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
 	}
 
 	persisted := ipSet(readClientIps(t, email))
-	if _, ok := persisted["10.1.0.1"]; !ok {
-		t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
+	if _, ok := persisted["192.0.2.9"]; !ok {
+		t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
 	}
-	if _, ok := persisted["192.0.2.9"]; ok {
-		t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
+	if _, ok := persisted["10.1.0.1"]; ok {
+		t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
 	}
 
 	// 3xipl.log must contain the ban line in the exact fail2ban format.
@@ -222,7 +223,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	if err != nil {
 		t.Fatalf("read 3xipl.log: %v", err)
 	}
-	wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
+	wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
 	if !contains(string(body), wantSubstr) {
 		t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
 	}

+ 4 - 3
web/job/check_client_ip_job_test.go

@@ -107,9 +107,10 @@ func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing
 	}
 }
 
-func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) {
-	// keep the "protect original, ban newcomer" policy when several ips
-	// are really live. with limit=1, A must stay and B must be banned.
+func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
+	// when several ips are really live, partition returns them all in the
+	// live set sorted ascending by timestamp. updateInboundClientIps then
+	// keeps the newest and bans the oldest (last-IP-wins, #4699).
 	ipMap := map[string]int64{
 		"A": 5000,
 		"B": 5500,