6 커밋 fc08b53395 ... 8f162994ef

작성자 SHA1 메시지 날짜
  YoungReckless4 8f162994ef feat(clients): let admins set PersistentKeepalive on tunnel clients (#6377) 11 시간 전
  duqigit 64b6e43e2b feat(sub): add legacy Clash subscription endpoint (#6338) 11 시간 전
  Sanaei 3f1e52f09e refactor(panel): drop two duplicated helpers 12 시간 전
  Sanaei 0fbdf0f9bf fix(ui): keep the empty-group placeholder legible in dark mode 12 시간 전
  Sanaei 02f2a63c53 refactor(tgbot): extract the shared numeric keypad builder 12 시간 전
  Sanaei 8b9cf260b6 perf(clients): batch the client record lookup in bulk operations 12 시간 전
51개의 변경된 파일947개의 추가작업 그리고 343개의 파일을 삭제
  1. 26 5
      docs/content/docs/en/config/subscription.mdx
  2. 22 5
      docs/content/docs/zh/config/subscription.mdx
  3. 2 0
      docs/public/openapi.json
  4. 2 0
      frontend/public/openapi.json
  5. 1 1
      frontend/src/generated/examples.ts
  6. 2 0
      frontend/src/generated/schemas.ts
  7. 1 1
      frontend/src/generated/types.ts
  8. 1 1
      frontend/src/generated/zod.ts
  9. 12 0
      frontend/src/pages/clients/ClientFormModal.tsx
  10. 2 1
      frontend/src/pages/clients/ClientsPage.tsx
  11. 1 1
      frontend/src/pages/groups/GroupAddClientsModal.tsx
  12. 1 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  13. 2 1
      internal/amneziawgnet/dns.go
  14. 2 1
      internal/amneziawgnet/egress.go
  15. 0 16
      internal/amneziawgnet/outbound_manager.go
  16. 26 5
      internal/database/model/model.go
  17. 21 2
      internal/database/model/model_wireguard_test.go
  18. 119 5
      internal/sub/clash_service.go
  19. 81 2
      internal/sub/clash_service_test.go
  20. 62 5
      internal/sub/controller.go
  21. 216 0
      internal/sub/controller_test.go
  22. 2 2
      internal/sub/json_service.go
  23. 1 1
      internal/sub/json_service_test.go
  24. 4 4
      internal/sub/service.go
  25. 1 1
      internal/sub/service_amneziawg_test.go
  26. 2 2
      internal/web/runtime/local.go
  27. 17 73
      internal/web/service/client_bulk.go
  28. 87 0
      internal/web/service/client_bulk_reset_query_count_test.go
  29. 5 5
      internal/web/service/client_inbound_apply.go
  30. 22 0
      internal/web/service/client_lookup.go
  31. 14 22
      internal/web/service/client_traffic.go
  32. 102 0
      internal/web/service/client_update_keepalive_test.go
  33. 2 2
      internal/web/service/client_wireguard.go
  34. 12 0
      internal/web/service/inbound_util.go
  35. 0 9
      internal/web/service/setting.go
  36. 41 0
      internal/web/service/tgbot/numeric_input.go
  37. 6 168
      internal/web/service/tgbot/tgbot_router.go
  38. 1 1
      internal/web/service/xray_wireguard_config_test.go
  39. 2 0
      internal/web/translation/ar-EG.json
  40. 2 0
      internal/web/translation/en-US.json
  41. 2 0
      internal/web/translation/es-ES.json
  42. 2 0
      internal/web/translation/fa-IR.json
  43. 2 0
      internal/web/translation/id-ID.json
  44. 2 0
      internal/web/translation/ja-JP.json
  45. 2 0
      internal/web/translation/pt-BR.json
  46. 2 0
      internal/web/translation/ru-RU.json
  47. 2 0
      internal/web/translation/tr-TR.json
  48. 2 0
      internal/web/translation/uk-UA.json
  49. 2 0
      internal/web/translation/vi-VN.json
  50. 2 0
      internal/web/translation/zh-CN.json
  51. 2 0
      internal/web/translation/zh-TW.json

+ 26 - 5
docs/content/docs/en/config/subscription.mdx

@@ -42,17 +42,38 @@ URLs and preview both bodies here:
 
 The **format is chosen by path**, each with its own enable toggle:
 
-| Format                | Path      | Enabled by       | Output                                              |
-| --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **Raw links**         | `subPath`      | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
-| **JSON**              | `subJsonPath`  | `subJsonEnable`  | Full Xray client config(s).                         |
-| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML profile.                                       |
+| Format                         | Path             | Enabled by       | Output                                              |
+| ------------------------------ | ---------------- | ---------------- | --------------------------------------------------- |
+| **Raw links**                  | `subPath`        | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
+| **JSON**                       | `subJsonPath`    | `subJsonEnable`  | Full Xray client config(s).                         |
+| **Clash / Mihomo**             | `subClashPath`   | `subClashEnable` | Full Mihomo-compatible YAML profile.                |
+| **Mihomo (explicit)**          | `/mihomo/`       | `subClashEnable` | Alias for the full `subClashPath` profile.          |
+| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
 
 Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, or Hysteria2**
 appear in a subscription, ordered by their sub-sort index. Requesting `subPath`
 with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
 page instead of the raw body.
 
+Use `/mihomo/<sub-id>` for Clash Verge Rev, Mihomo, and other maintained
+Mihomo-based clients. Use `/clash-legacy/<sub-id>` only for the discontinued
+Clash for Windows client. The legacy endpoint keeps compatible VMess, Trojan,
+and Shadowsocks nodes and excludes VLESS, Hysteria2, Reality, XHTTP,
+HTTPUpgrade, and Shadowsocks 2022. If no compatible node exists, it returns an
+explicit `422` response instead of a YAML profile the client cannot import.
+To avoid Mihomo-only syntax entering the legacy profile, this endpoint always
+uses its minimal `PROXY` group and `MATCH,PROXY` rule and ignores custom Clash
+routing settings.
+
+If an administrator has already assigned `/mihomo/` or `/clash-legacy/` to a
+different configurable subscription path, that existing path is preserved and
+the conflicting alias is skipped with a warning at startup.
+
+Automatic Clash format detection keeps the existing `(?i)(clash|mihomo)`
+default matcher so existing subscription URLs continue returning YAML.
+It does not distinguish legacy clients from Mihomo-based clients; Clash for
+Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
+
 ### Base64 vs JSON
 
 The **Base64** body is just the newline-joined share links, standard-base64

+ 22 - 5
docs/content/docs/zh/config/subscription.mdx

@@ -36,14 +36,31 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 
 **格式由路径决定**,每种格式都有各自的启用开关:
 
-| 格式                  | 路径      | 启用方式         | 输出                                                |
-| --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **原始链接**          | `subPath`      | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
-| **JSON**              | `subJsonPath`  | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
-| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML 配置文件。                                     |
+| 格式                          | 路径             | 启用方式         | 输出                                                |
+| ----------------------------- | ---------------- | ---------------- | --------------------------------------------------- |
+| **原始链接**                  | `subPath`        | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
+| **JSON**                      | `subJsonPath`    | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
+| **Clash / Mihomo**            | `subClashPath`   | `subClashEnable` | 完整的 Mihomo 兼容 YAML 配置。                      |
+| **Mihomo(明确端点)**        | `/mihomo/`       | `subClashEnable` | 完整 `subClashPath` 配置的别名。                    |
+| **Clash for Windows(旧版)** | `/clash-legacy/` | `subClashEnable` | 仅包含旧 Clash 内核支持的代理类型、传输方式和加密算法。 |
 
 只有使用 **VLESS、VMess、Trojan、Shadowsocks 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列。使用 `Accept: text/html` 头(或 `?html=1`)请求 `subPath` 会返回一个人类可读的信息页面,而非原始内容。
 
+Clash Verge Rev、Mihomo 及其他仍在维护的 Mihomo 客户端应使用
+`/mihomo/<sub-id>`。已经停止维护的 Clash for Windows 应使用
+`/clash-legacy/<sub-id>`;旧版端点只保留兼容的 VMess、Trojan 和
+Shadowsocks 节点,并排除 VLESS、Hysteria2、Reality、XHTTP、HTTPUpgrade
+和 Shadowsocks 2022。如果没有任何兼容节点,端点会明确返回 `422`,而不是返回一份无法导入的 YAML。
+为避免 Mihomo 专用语法进入旧版配置,此端点始终使用最小的 `PROXY` 策略组与
+`MATCH,PROXY` 规则,并忽略自定义 Clash 路由设置。
+
+如果管理员已经把 `/mihomo/` 或 `/clash-legacy/` 分配给其他可配置订阅路径,
+系统会保留原有路径,并在启动时记录警告、跳过发生冲突的别名。
+
+Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,确保已有订阅 URL
+继续返回 YAML。它不区分旧版客户端与 Mihomo 系客户端;Clash for Windows 用户
+必须使用 `/clash-legacy/<sub-id>` 获取兼容配置。
+
 ### Base64 与 JSON
 
 **Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(绑定到 127.0.0.1 的本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。

+ 2 - 0
docs/public/openapi.json

@@ -1365,6 +1365,8 @@
             "type": "string"
           },
           "keepAlive": {
+            "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
+            "nullable": true,
             "type": "integer"
           },
           "limitIp": {

+ 2 - 0
frontend/public/openapi.json

@@ -1365,6 +1365,8 @@
             "type": "string"
           },
           "keepAlive": {
+            "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
+            "nullable": true,
             "type": "integer"
           },
           "limitIp": {

+ 1 - 1
frontend/src/generated/examples.ts

@@ -329,7 +329,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "forwardedPorts": "",
     "group": "",
     "id": "",
-    "keepAlive": 0,
+    "keepAlive": null,
     "limitIp": 0,
     "password": "",
     "preSharedKey": "",

+ 2 - 0
frontend/src/generated/schemas.ts

@@ -1339,6 +1339,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "string"
       },
       "keepAlive": {
+        "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
+        "nullable": true,
         "type": "integer"
       },
       "limitIp": {

+ 1 - 1
frontend/src/generated/types.ts

@@ -325,7 +325,7 @@ export interface Client {
   forwardedPorts?: string;
   group?: string;
   id?: string;
-  keepAlive?: number;
+  keepAlive?: number | null;
   limitIp: number;
   password?: string;
   preSharedKey?: string;

+ 1 - 1
frontend/src/generated/zod.ts

@@ -346,7 +346,7 @@ export const ClientSchema = z.object({
   forwardedPorts: z.string().optional(),
   group: z.string().optional(),
   id: z.string().optional(),
-  keepAlive: z.number().int().optional(),
+  keepAlive: z.number().int().nullable().optional(),
   limitIp: z.number().int(),
   password: z.string().optional(),
   preSharedKey: z.string().optional(),

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

@@ -133,6 +133,7 @@ type Values = ClientFormValues & {
   wgAllowedIPs: string;
   awgAllowedIPs: string;
   awgForwardedPorts: string;
+  wgKeepAlive: number;
   secret: string;
   adTag: string;
 };
@@ -169,6 +170,7 @@ const EMPTY: Values = {
   wgAllowedIPs: '',
   awgAllowedIPs: '',
   awgForwardedPorts: '',
+  wgKeepAlive: 25,
   secret: '',
   adTag: '',
 };
@@ -379,6 +381,7 @@ export default function ClientFormModal({
         wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
         awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
         awgForwardedPorts: client.forwardedPorts || '',
+        wgKeepAlive: client.keepAlive ?? 0,
         secret: client.secret || '',
         adTag: client.adTag || '',
       };
@@ -694,6 +697,7 @@ export default function ClientFormModal({
       // so both protocols share this one field set — see wgPrivateKey etc.
       // below and the AmneziaWG-labeled variants of the same inputs.
       clientPayload.privateKey = values.wgPrivateKey;
+      clientPayload.keepAlive = values.wgKeepAlive;
       clientPayload.publicKey = values.wgPublicKey;
       if (values.wgPreSharedKey) {
         clientPayload.preSharedKey = values.wgPreSharedKey;
@@ -1272,6 +1276,14 @@ export default function ClientFormModal({
                               <Input placeholder="10.8.1.2/32" />
                             </FormField>
                           )}
+                          <FormField
+                            name="wgKeepAlive"
+                            label={t('pages.clients.tunnelKeepAlive')}
+                            extra={t('pages.clients.tunnelKeepAliveHint')}
+                            transform={{ output: (v) => Number(v) || 0 }}
+                          >
+                            <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+                          </FormField>
                           {showAmneziawg && (
                             <FormField
                               name="awgForwardedPorts"

+ 2 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -23,6 +23,7 @@ import {
   Table,
   Tag,
   Tooltip,
+  Typography,
   message,
 } from 'antd';
 import type { ColumnsType, TableProps } from 'antd/es/table';
@@ -1091,7 +1092,7 @@ export default function ClientsPage() {
         width: 130,
         hidden: allGroups.length === 0,
         render: (_v, record) => {
-          if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
+          if (!record.group) return <Typography.Text type="secondary">—</Typography.Text>;
           const isActive = filters.groups.includes(record.group);
           return (
             <Tag

+ 1 - 1
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -82,7 +82,7 @@ export default function GroupAddClientsModal({
           g ? (
             <Tag color="geekblue">{g}</Tag>
           ) : (
-            <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
+            <Typography.Text type="secondary">—</Typography.Text>
           ),
       },
       {

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -125,7 +125,7 @@ export default function AttachExistingClientsModal({
           group ? (
             <Tag color="geekblue">{group}</Tag>
           ) : (
-            <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
+            <Typography.Text type="secondary">—</Typography.Text>
           ),
       },
       {

+ 2 - 1
internal/amneziawgnet/dns.go

@@ -14,6 +14,7 @@ import (
 	"gvisor.dev/gvisor/pkg/tcpip"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
@@ -78,7 +79,7 @@ func dnsCacheKey(tag, dnsServer, host string) string {
 }
 
 func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) {
-	normDNS := normalizeDNSServer(dnsServer)
+	normDNS := amneziawg.NormalizeDNSServer(dnsServer)
 	if normDNS == "" {
 		normDNS = defaultDNSFor(dev.LocalAddresses())
 	}

+ 2 - 1
internal/amneziawgnet/egress.go

@@ -14,6 +14,7 @@ import (
 	"gvisor.dev/gvisor/pkg/tcpip"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
@@ -77,7 +78,7 @@ func (s *socks5EgressServer) SetDNSServer(addr string) {
 func (s *socks5EgressServer) SetStack(tag string, dev *Device, dnsServer ...string) {
 	norm := ""
 	if len(dnsServer) > 0 && dnsServer[0] != "" {
-		norm = normalizeDNSServer(dnsServer[0])
+		norm = amneziawg.NormalizeDNSServer(dnsServer[0])
 	}
 	s.mu.Lock()
 	prevDev := s.stacks[tag]

+ 0 - 16
internal/amneziawgnet/outbound_manager.go

@@ -2,7 +2,6 @@ package amneziawgnet
 
 import (
 	"fmt"
-	"net/netip"
 	"strings"
 	"sync"
 
@@ -53,21 +52,6 @@ func outboundFingerprint(inst amneziawg.OutboundInstance) string {
 		strings.Join(inst.Address, ","))
 }
 
-// normalizeDNSServer normalizes a configured DNS server to host:port.
-func normalizeDNSServer(s string) string {
-	s = strings.TrimSpace(s)
-	if s == "" {
-		return ""
-	}
-	if addr, err := netip.ParseAddr(s); err == nil {
-		return netip.AddrPortFrom(addr, 53).String()
-	}
-	if ap, err := netip.ParseAddrPort(s); err == nil {
-		return ap.String()
-	}
-	return s
-}
-
 // Reconcile converges devices to desired and stops removed tags; per-tick
 // contract of Manager.Reconcile -- errors log, never abort the batch.
 func (m *OutboundManager) Reconcile(desired []OutboundDesired) {

+ 26 - 5
internal/database/model/model.go

@@ -441,8 +441,8 @@ func WireguardPeerFromClient(c Client) map[string]any {
 	if c.PreSharedKey != "" {
 		peer["preSharedKey"] = c.PreSharedKey
 	}
-	if c.KeepAlive > 0 {
-		peer["keepAlive"] = c.KeepAlive
+	if ka := c.KeepAliveSeconds(); ka > 0 {
+		peer["keepAlive"] = ka
 	}
 	return peer
 }
@@ -890,7 +890,7 @@ type Client struct {
 	// before -- fully backward compatible for callers that never set this.
 	AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
 	PreSharedKey        string           `json:"preSharedKey,omitempty"`
-	KeepAlive           int              `json:"keepAlive,omitempty"`
+	KeepAlive           *int             `json:"keepAlive,omitempty"`      // Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value
 	ForwardedPorts      string           `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
 	Secret              string           `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
 	AdTag               string           `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
@@ -1113,6 +1113,27 @@ type Host struct {
 
 func (Host) TableName() string { return "hosts" }
 
+// KeepAliveSeconds is the client's PersistentKeepalive, 0 when unset.
+func (c Client) KeepAliveSeconds() int {
+	if c.KeepAlive == nil {
+		return 0
+	}
+	return *c.KeepAlive
+}
+
+// KeepAlivePtr wraps an explicit PersistentKeepalive, 0 included -- distinct
+// from a nil KeepAlive, which means the field was never sent.
+func KeepAlivePtr(v int) *int { return &v }
+
+// nonZeroKeepAlive keeps a stored 0 nil: wg_keep_alive cannot tell "off" from
+// "never set", and an explicit 0 would emit keepAlive on every protocol.
+func nonZeroKeepAlive(seconds int) *int {
+	if seconds <= 0 {
+		return nil
+	}
+	return KeepAlivePtr(seconds)
+}
+
 func (c *Client) ToRecord() *ClientRecord {
 	rec := &ClientRecord{
 		Email:           c.Email,
@@ -1141,7 +1162,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		PublicKey:      c.PublicKey,
 		AllowedIPs:     strings.Join(c.AllowedIPs, ","),
 		PreSharedKey:   c.PreSharedKey,
-		KeepAlive:      c.KeepAlive,
+		KeepAlive:      c.KeepAliveSeconds(),
 		ForwardedPorts: c.ForwardedPorts,
 		Secret:         c.Secret,
 		AdTag:          c.AdTag,
@@ -1199,7 +1220,7 @@ func (r *ClientRecord) ToClient() *Client {
 		PublicKey:      r.PublicKey,
 		AllowedIPs:     splitWireguardAllowedIPs(r.AllowedIPs),
 		PreSharedKey:   r.PreSharedKey,
-		KeepAlive:      r.KeepAlive,
+		KeepAlive:      nonZeroKeepAlive(r.KeepAlive),
 		ForwardedPorts: r.ForwardedPorts,
 		Secret:         r.Secret,
 		AdTag:          r.AdTag,

+ 21 - 2
internal/database/model/model_wireguard_test.go

@@ -1,7 +1,9 @@
 package model
 
 import (
+	"encoding/json"
 	"reflect"
+	"strings"
 	"testing"
 )
 
@@ -13,7 +15,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
 		PublicKey:    "cGVlci1wdWJsaWMta2V5LWJhc2U2NC0zMmJ5dGVzISE=",
 		AllowedIPs:   []string{"10.0.0.2/32", "fd00::2/128"},
 		PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=",
-		KeepAlive:    25,
+		KeepAlive:    KeepAlivePtr(25),
 	}
 
 	rec := c.ToRecord()
@@ -29,7 +31,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
 		{"PrivateKey", c.PrivateKey, got.PrivateKey},
 		{"PublicKey", c.PublicKey, got.PublicKey},
 		{"PreSharedKey", c.PreSharedKey, got.PreSharedKey},
-		{"KeepAlive", c.KeepAlive, got.KeepAlive},
+		{"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()},
 	} {
 		if f.a != f.b {
 			t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a)
@@ -40,6 +42,23 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) {
 	}
 }
 
+// ToClient feeds the settings JSON of every protocol, not just the tunnels, and
+// that JSON reaches xray-core verbatim through GenXrayInboundConfig.
+func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) {
+	rec := &ClientRecord{Email: "[email protected]", UUID: "11111111-2222-3333-4444-555555555555", Enable: true}
+
+	if got := rec.ToClient().KeepAlive; got != nil {
+		t.Fatalf("KeepAlive for a record that never set one = %d, want nil", *got)
+	}
+	blob, err := json.Marshal(map[string][]Client{"clients": {*rec.ToClient()}})
+	if err != nil {
+		t.Fatalf("marshal settings payload: %v", err)
+	}
+	if strings.Contains(string(blob), "keepAlive") {
+		t.Fatalf("settings payload carries keepAlive for a non-tunnel client: %s", blob)
+	}
+}
+
 func TestClientRecordEmptyAllowedIPs(t *testing.T) {
 	rec := &ClientRecord{Email: "[email protected]", AllowedIPs: ""}
 	if got := rec.ToClient().AllowedIPs; got != nil {

+ 119 - 5
internal/sub/clash_service.go

@@ -22,11 +22,21 @@ type SubClashService struct {
 	SubService    *SubService
 }
 
+var errNoLegacyClashProxies = errors.New("no Clash for Windows-compatible proxies found; use the Mihomo subscription for modern proxy types")
+
 func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
 	return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
 }
 
 func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
+	return s.getClash(subId, host, false)
+}
+
+func (s *SubClashService) GetClashLegacy(subId string, host string) (string, string, error) {
+	return s.getClash(subId, host, true)
+}
+
+func (s *SubClashService) getClash(subId string, host string, legacy bool) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
 	subReq.subscriptionBody = true
 	inbounds, err := subReq.getInboundsBySubId(subId)
@@ -87,6 +97,12 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	if len(proxies) == 0 && !hasInactiveExternal {
 		return "", "", nil
 	}
+	if legacy {
+		proxies = legacyClashProxies(proxies)
+		if len(proxies) == 0 {
+			return "", "", errNoLegacyClashProxies
+		}
+	}
 
 	emails := make([]string, 0, len(seenEmails))
 	for e := range seenEmails {
@@ -138,7 +154,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		"rules": []string{"MATCH,PROXY"},
 	}
 
-	if s.enableRouting {
+	// Custom Clash routing can inject Mihomo-only groups, rules, providers or a
+	// top-level proxies key — exactly what the legacy filter just removed.
+	if s.enableRouting && !legacy {
 		resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
 		if resolveErr == nil && strings.TrimSpace(resolved) != "" {
 			if remote {
@@ -159,6 +177,102 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	return string(finalYAML), header, nil
 }
 
+func legacyClashProxies(proxies []map[string]any) []map[string]any {
+	compatible := make([]map[string]any, 0, len(proxies))
+	for _, proxy := range proxies {
+		if filtered := legacyClashProxy(proxy); filtered != nil {
+			compatible = append(compatible, filtered)
+		}
+	}
+	return compatible
+}
+
+func legacyClashProxy(proxy map[string]any) map[string]any {
+	proxyType, _ := proxy["type"].(string)
+	network, _ := proxy["network"].(string)
+	if _, reality := proxy["reality-opts"]; reality {
+		return nil
+	}
+
+	var fields []string
+	var cipher string
+	switch proxyType {
+	case "vmess":
+		if !legacyClashNetwork(network) || !legacyVmessCipher(proxy["cipher"]) {
+			return nil
+		}
+		fields = []string{
+			"name", "type", "server", "port", "uuid", "alterId", "cipher", "udp",
+			"network", "tls", "skip-cert-verify", "servername", "grpc-opts", "ws-opts",
+		}
+	case "trojan":
+		tls, _ := proxy["tls"].(bool)
+		if !tls || !legacyClashNetwork(network) {
+			return nil
+		}
+		fields = []string{
+			"name", "type", "server", "port", "password", "alpn", "sni", "skip-cert-verify",
+			"udp", "network", "grpc-opts", "ws-opts",
+		}
+	case "ss":
+		tls, _ := proxy["tls"].(bool)
+		cipher = legacyShadowsocksCipher(proxy["cipher"])
+		if (network != "" && network != "tcp") || tls || cipher == "" {
+			return nil
+		}
+		fields = []string{"name", "type", "server", "port", "password", "cipher", "udp", "plugin", "plugin-opts"}
+	default:
+		return nil
+	}
+
+	filtered := make(map[string]any, len(fields))
+	for _, field := range fields {
+		if value, exists := proxy[field]; exists {
+			filtered[field] = value
+		}
+	}
+	if proxyType == "ss" {
+		filtered["cipher"] = cipher
+	}
+	return filtered
+}
+
+func legacyClashNetwork(network string) bool {
+	switch network {
+	case "", "tcp", "ws", "grpc":
+		return true
+	default:
+		return false
+	}
+}
+
+func legacyVmessCipher(value any) bool {
+	cipher, _ := value.(string)
+	switch strings.ToLower(strings.TrimSpace(cipher)) {
+	case "auto", "aes-128-gcm", "chacha20-poly1305", "none":
+		return true
+	default:
+		return false
+	}
+}
+
+func legacyShadowsocksCipher(value any) string {
+	cipher, _ := value.(string)
+	cipher = strings.ToLower(strings.TrimSpace(cipher))
+	switch cipher {
+	case "chacha20-poly1305":
+		return "chacha20-ietf-poly1305"
+	case "aes-128-gcm", "aes-192-gcm", "aes-256-gcm",
+		"aes-128-cfb", "aes-192-cfb", "aes-256-cfb",
+		"aes-128-ctr", "aes-192-ctr", "aes-256-ctr",
+		"rc4-md5", "chacha20-ietf", "xchacha20",
+		"chacha20-ietf-poly1305", "xchacha20-ietf-poly1305":
+		return cipher
+	default:
+		return ""
+	}
+}
+
 // 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
@@ -465,8 +579,8 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
 	if client.PreSharedKey != "" {
 		proxy["pre-shared-key"] = client.PreSharedKey
 	}
-	if client.KeepAlive > 0 {
-		proxy["persistent-keepalive"] = client.KeepAlive
+	if ka := client.KeepAliveSeconds(); ka > 0 {
+		proxy["persistent-keepalive"] = ka
 	}
 	for _, addr := range client.AllowedIPs {
 		ip := stripCIDR(addr)
@@ -553,8 +667,8 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
 	if client.PreSharedKey != "" {
 		proxy["pre-shared-key"] = client.PreSharedKey
 	}
-	if client.KeepAlive > 0 {
-		proxy["persistent-keepalive"] = client.KeepAlive
+	if ka := client.KeepAliveSeconds(); ka > 0 {
+		proxy["persistent-keepalive"] = ka
 	}
 
 	for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) {

+ 81 - 2
internal/sub/clash_service_test.go

@@ -44,6 +44,85 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
 	}
 }
 
+func TestLegacyClashProxyCompatibility(t *testing.T) {
+	t.Run("keeps legacy vmess fields", func(t *testing.T) {
+		proxy := map[string]any{
+			"name": "vm", "type": "vmess", "server": "vm.example.com", "port": 443,
+			"uuid": "11111111-2222-4333-8444-555555555555", "alterId": 0, "cipher": "auto",
+			"udp": true, "network": "ws", "tls": true, "servername": "sni.example.com",
+			"ws-opts": map[string]any{"path": "/ws"}, "client-fingerprint": "chrome", "alpn": []string{"h2"},
+		}
+
+		got := legacyClashProxy(proxy)
+		if got == nil || got["type"] != "vmess" || got["network"] != "ws" {
+			t.Fatalf("legacy vmess was filtered or changed: %#v", got)
+		}
+		for _, field := range []string{"client-fingerprint", "alpn"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("Mihomo-only field %q leaked into legacy vmess: %#v", field, got)
+			}
+		}
+	})
+
+	t.Run("keeps legacy trojan fields", func(t *testing.T) {
+		proxy := map[string]any{
+			"name": "tr", "type": "trojan", "server": "tr.example.com", "port": 443,
+			"password": "secret", "udp": true, "network": "grpc", "tls": true,
+			"sni": "sni.example.com", "servername": "sni.example.com", "alpn": []string{"h2"},
+			"grpc-opts": map[string]any{"grpc-service-name": "svc"},
+		}
+
+		got := legacyClashProxy(proxy)
+		if got == nil || got["type"] != "trojan" || got["sni"] != "sni.example.com" {
+			t.Fatalf("legacy trojan was filtered or changed: %#v", got)
+		}
+		for _, field := range []string{"tls", "servername"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("field %q is not part of the legacy Trojan schema: %#v", field, got)
+			}
+		}
+
+		withoutTLS := cloneMap(proxy)
+		withoutTLS["tls"] = false
+		if got := legacyClashProxy(withoutTLS); got != nil {
+			t.Fatalf("Trojan without TLS must not reach Clash for Windows: %#v", got)
+		}
+	})
+
+	t.Run("keeps only legacy shadowsocks ciphers", func(t *testing.T) {
+		legacy := map[string]any{
+			"name": "ss", "type": "ss", "server": "ss.example.com", "port": 443,
+			"password": "secret", "cipher": "aes-256-gcm", "udp": true, "network": "tcp", "tls": false,
+		}
+		got := legacyClashProxy(legacy)
+		if got == nil || got["type"] != "ss" {
+			t.Fatalf("legacy Shadowsocks proxy was filtered: %#v", got)
+		}
+		for _, field := range []string{"network", "tls"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("field %q is not part of the legacy Shadowsocks schema: %#v", field, got)
+			}
+		}
+
+		ss2022 := cloneMap(legacy)
+		ss2022["cipher"] = "2022-blake3-aes-256-gcm"
+		if got := legacyClashProxy(ss2022); got != nil {
+			t.Fatalf("SS-2022 must not reach Clash for Windows: %#v", got)
+		}
+	})
+
+	for _, proxy := range []map[string]any{
+		{"name": "vl", "type": "vless"},
+		{"name": "hy", "type": "hysteria2"},
+		{"name": "xh", "type": "vmess", "cipher": "auto", "network": "xhttp"},
+		{"name": "reality", "type": "vmess", "cipher": "auto", "network": "tcp", "reality-opts": map[string]any{}},
+	} {
+		if got := legacyClashProxy(proxy); got != nil {
+			t.Fatalf("modern proxy reached Clash for Windows: %#v", got)
+		}
+	}
+}
+
 // TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
 // applySecurity (clash_service.go ~488): a regression that drops servername,
 // public-key, short-id, or client-fingerprint would hand mihomo a broken reality
@@ -830,7 +909,7 @@ func TestBuildWireguardProxyForClash(t *testing.T) {
 		Email:        "user",
 		PrivateKey:   clientPriv,
 		PreSharedKey: "psk-value",
-		KeepAlive:    25,
+		KeepAlive:    model.KeepAlivePtr(25),
 		AllowedIPs:   []string{"10.0.0.2/32", "fd00::2/128"},
 	}
 
@@ -910,7 +989,7 @@ func TestBuildAmneziaWGProxyForClash(t *testing.T) {
 		Email:        "user",
 		PrivateKey:   clientPriv,
 		PreSharedKey: "psk-value",
-		KeepAlive:    25,
+		KeepAlive:    model.KeepAlivePtr(25),
 		AllowedIPs:   []string{"10.8.1.2/32", "fd00::2/128"},
 	}
 

+ 62 - 5
internal/sub/controller.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"html/template"
 	"io/fs"
@@ -23,6 +24,11 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
+const (
+	subMihomoPath      = "/mihomo/"
+	subClashLegacyPath = "/clash-legacy/"
+)
+
 // writeSubError translates a service-layer result into an HTTP response.
 // A nil error with no rows means the subId doesn't match anything (deleted
 // client, never-existed id) and becomes 404. A real error becomes 500. No
@@ -314,7 +320,40 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 		gClash := g.Group(a.subClashPath)
 		gClash.GET(":subid", a.subClashs)
 		gClash.HEAD(":subid", a.subClashs)
+		if sameSubscriptionPath(a.subClashPath, subMihomoPath) {
+			// The configured Clash path already provides the full Mihomo profile.
+		} else if owner := a.configuredSubscriptionPathOwner(subMihomoPath); owner != "" {
+			logger.Warningf("Mihomo subscription alias %q is unavailable because it conflicts with the configured %s path", subMihomoPath, owner)
+		} else {
+			gMihomo := g.Group(subMihomoPath)
+			gMihomo.GET(":subid", a.subClashs)
+			gMihomo.HEAD(":subid", a.subClashs)
+		}
+		if owner := a.configuredSubscriptionPathOwner(subClashLegacyPath); owner != "" {
+			logger.Warningf("Legacy Clash subscription alias %q is unavailable because it conflicts with the configured %s path", subClashLegacyPath, owner)
+		} else {
+			gLegacy := g.Group(subClashLegacyPath)
+			gLegacy.GET(":subid", a.subClashLegacy)
+			gLegacy.HEAD(":subid", a.subClashLegacy)
+		}
+	}
+}
+
+func sameSubscriptionPath(left, right string) bool {
+	return strings.Trim(left, "/") == strings.Trim(right, "/")
+}
+
+func (a *SUBController) configuredSubscriptionPathOwner(candidate string) string {
+	if sameSubscriptionPath(candidate, a.subPath) {
+		return "raw subscription"
+	}
+	if a.jsonEnabled && sameSubscriptionPath(candidate, a.subJsonPath) {
+		return "JSON subscription"
+	}
+	if a.clashEnabled && sameSubscriptionPath(candidate, a.subClashPath) {
+		return "Clash subscription"
 	}
+	return ""
 }
 
 // maybeServeSubPage renders the HTML info page when the request comes from a
@@ -411,7 +450,7 @@ func (a *SUBController) subs(c *gin.Context) {
 	if !a.enforceHwid(c) {
 		return
 	}
-	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false, false) {
 		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "clash")
 		return
@@ -777,11 +816,19 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 }
 
 func (a *SUBController) subClashs(c *gin.Context) {
+	a.subClash(c, false)
+}
+
+func (a *SUBController) subClashLegacy(c *gin.Context) {
+	a.subClash(c, true)
+}
+
+func (a *SUBController) subClash(c *gin.Context, legacy bool) {
 	if strings.EqualFold(c.Query("view"), "raw") {
 		if !a.enforceHwid(c) {
 			return
 		}
-		if !a.serveClashBody(c, true) {
+		if !a.serveClashBody(c, true, legacy) {
 			writeSubError(c, nil)
 		}
 		a.recordSubscriptionFetch(c)
@@ -793,17 +840,27 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	if !a.enforceHwid(c) {
 		return
 	}
-	if !a.serveClashBody(c, false) {
+	if !a.serveClashBody(c, false, legacy) {
 		writeSubError(c, nil)
 	}
 	a.recordSubscriptionFetch(c)
 }
 
-func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
+func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy bool) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
-	clashSub, header, err := a.subClashService.GetClash(subId, host)
+	var clashSub, header string
+	var err error
+	if legacy {
+		clashSub, header, err = a.subClashService.GetClashLegacy(subId, host)
+	} else {
+		clashSub, header, err = a.subClashService.GetClash(subId, host)
+	}
 	if err != nil {
+		if errors.Is(err, errNoLegacyClashProxies) {
+			c.String(http.StatusUnprocessableEntity, err.Error())
+			return true
+		}
 		writeSubError(c, err)
 		return true
 	}

+ 216 - 0
internal/sub/controller_test.go

@@ -92,6 +92,89 @@ func TestNewSUBControllerOptions(t *testing.T) {
 	}
 }
 
+// A configured subscription path keeps its own format when it collides with a
+// hard-coded Clash alias, and the alias that does not collide still serves.
+func TestClashAliasesSkipConfiguredPathConflicts(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vm", 4487, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+	seedSubInbound(t, "s1", "vl", 4488, 2, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	type check struct {
+		path    string
+		want    []string
+		notWant []string
+	}
+	// The full Mihomo profile is the only body carrying "type: vless"; the
+	// legacy one keeps VMess and drops it.
+	fullProfile := []string{"type: vmess", "type: vless"}
+	legacyProfile := []string{"type: vmess"}
+
+	tests := []struct {
+		name    string
+		options []SUBControllerOption
+		checks  []check
+	}{
+		{
+			name:    "raw path uses Mihomo alias",
+			options: []SUBControllerOption{WithSUBPath(subMihomoPath), WithSUBEncryption(false)},
+			checks: []check{
+				{path: "/mihomo/s1", want: []string{"vmess://"}, notWant: []string{"type: vmess"}},
+				{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
+			},
+		},
+		{
+			name:    "JSON path uses legacy alias",
+			options: []SUBControllerOption{WithSUBJsonEnabled(true), WithSUBJsonPath(subClashLegacyPath)},
+			checks: []check{
+				{path: "/clash-legacy/s1", want: []string{`"outbounds"`}, notWant: []string{"type: vmess"}},
+				{path: "/mihomo/s1", want: fullProfile},
+			},
+		},
+		{
+			name:    "configured Clash path is already Mihomo alias",
+			options: []SUBControllerOption{WithSUBClashPath(subMihomoPath)},
+			checks: []check{
+				{path: "/mihomo/s1", want: fullProfile},
+				{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
+			},
+		},
+		{
+			name:    "configured Clash path uses legacy alias",
+			options: []SUBControllerOption{WithSUBClashPath(subClashLegacyPath)},
+			checks: []check{
+				{path: "/clash-legacy/s1", want: fullProfile},
+				{path: "/mihomo/s1", want: fullProfile},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			router := gin.New()
+			NewSUBController(router.Group("/"), append([]SUBControllerOption{WithSUBClashEnabled(true)}, tt.options...)...)
+			for _, c := range tt.checks {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+c.path, nil))
+				if resp.Code != http.StatusOK {
+					t.Fatalf("GET %s: status = %d, want 200; body=%s", c.path, resp.Code, resp.Body.String())
+				}
+				body := resp.Body.String()
+				for _, want := range c.want {
+					if !strings.Contains(body, want) {
+						t.Fatalf("GET %s: body is missing %q:\n%s", c.path, want, body)
+					}
+				}
+				for _, notWant := range c.notWant {
+					if strings.Contains(body, notWant) {
+						t.Fatalf("GET %s: body must not contain %q:\n%s", c.path, notWant, body)
+					}
+				}
+			}
+		})
+	}
+}
+
 func TestShouldAutoServeClash(t *testing.T) {
 	tests := []struct {
 		name         string
@@ -106,6 +189,7 @@ func TestShouldAutoServeClash(t *testing.T) {
 		{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
 		{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
 		{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
+		{name: "clash for windows preserves existing detection", autoDetect: true, clashEnabled: true, userAgent: "ClashforWindows/0.20.39", want: true},
 		{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
 		{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
 		{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
@@ -396,6 +480,97 @@ func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
 	})
 }
 
+func TestExplicitMihomoAndLegacyClashEndpoints(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "vless", 4482, 1, `{"network":"tcp","security":"none"}`)
+	seedSubProtocolInbound(t, "s1", "vmess", 4483, 2, `{"network":"ws","security":"tls","wsSettings":{"path":"/ws"},"tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+
+	for _, path := range []string{"/clash/s1", "/mihomo/s1"} {
+		t.Run(path+" keeps the full Mihomo profile", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			if body := resp.Body.String(); !strings.Contains(body, "type: vless") || !strings.Contains(body, "type: vmess") {
+				t.Fatalf("full profile must keep VLESS and VMess:\n%s", body)
+			}
+		})
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	router.ServeHTTP(resp, req)
+	if resp.Code != http.StatusOK {
+		t.Fatalf("legacy status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+	}
+	if body := resp.Body.String(); !strings.Contains(body, "type: vmess") || strings.Contains(body, "type: vless") {
+		t.Fatalf("legacy profile must keep VMess and remove VLESS:\n%s", body)
+	}
+}
+
+func TestLegacyClashEndpointExplainsWhenNoCompatibleProxyExists(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "vless", 4484, 1, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+	if resp.Code != http.StatusUnprocessableEntity {
+		t.Fatalf("status = %d, want 422; body=%s", resp.Code, resp.Body.String())
+	}
+	if !strings.Contains(resp.Body.String(), "no Clash for Windows-compatible proxies") {
+		t.Fatalf("legacy endpoint did not explain the incompatibility: %s", resp.Body.String())
+	}
+}
+
+func TestLegacyClashEndpointIgnoresCustomMihomoRouting(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vmess", 4485, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBClashEnabled(true),
+		WithSUBClashEnableRouting(true),
+		WithSUBClashRules(`
+proxies:
+  - name: injected-modern-node
+    type: vless
+    server: modern.example.com
+    port: 443
+    uuid: 11111111-2222-4333-8444-555555555555
+proxy-groups:
+  - name: MIHOMO-ONLY
+    type: select
+    include-all: true
+rules:
+  - MATCH,MIHOMO-ONLY
+`),
+	)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	router.ServeHTTP(resp, req)
+
+	if resp.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+	}
+	body := resp.Body.String()
+	if strings.Contains(body, "injected-modern-node") || strings.Contains(body, "type: vless") || strings.Contains(body, "include-all") {
+		t.Fatalf("custom Mihomo routing leaked into legacy profile:\n%s", body)
+	}
+	if !strings.Contains(body, "type: vmess") || !strings.Contains(body, "MATCH,PROXY") {
+		t.Fatalf("legacy profile did not retain its compatible proxy and simple route:\n%s", body)
+	}
+}
+
 func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
 	seedSubDB(t)
 	seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
@@ -586,3 +761,44 @@ func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
 		t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
 	}
 }
+
+func TestStandardSubscriptionPreservesClashUserAgents(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vm", 4905, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true})
+	for _, ua := range []string{"mihomo/1.19.12", "clash.meta", "Clash.Meta/1.19.12", "ClashX Meta/1.0", "ClashforWindows/0.20.39"} {
+		t.Run(ua, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+			req.Header.Set("User-Agent", ua)
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+			if resp.Code != http.StatusOK || resp.Header().Get("Content-Type") != "application/yaml; charset=utf-8" || !strings.Contains(resp.Body.String(), "type: vmess") {
+				t.Fatalf("UA=%q: status=%d, content-type=%q; expected VMess YAML, body=%s", ua, resp.Code, resp.Header().Get("Content-Type"), resp.Body.String())
+			}
+		})
+	}
+}
+
+func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
+	for _, method := range []string{"chacha20-ietf-poly1305", "chacha20-poly1305"} {
+		t.Run(method, func(t *testing.T) {
+			seedSubDB(t)
+			ib := seedSubProtocolInbound(t, "s1", "ss", 4906, 1, `{"network":"tcp","security":"none"}`, model.Shadowsocks)
+			db := database.GetDB()
+			if err := db.Model(ib).Update("settings", fmt.Sprintf(`{"method":%q,"network":"tcp,udp"}`, method)).Error; err != nil {
+				t.Fatal(err)
+			}
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", "ss@e").Update("password", "test-password").Error; err != nil {
+				t.Fatal(err)
+			}
+			gin.SetMode(gin.TestMode)
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+			resp := httptest.NewRecorder()
+			newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+			if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "type: ss") || !strings.Contains(resp.Body.String(), "cipher: chacha20-ietf-poly1305") {
+				t.Fatalf("method=%s: status=%d, body=%s", method, resp.Code, resp.Body.String())
+			}
+		})
+	}
+}

+ 2 - 2
internal/sub/json_service.go

@@ -998,8 +998,8 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien
 	if client.PreSharedKey != "" {
 		peer["preSharedKey"] = client.PreSharedKey
 	}
-	if client.KeepAlive > 0 {
-		peer["keepAlive"] = client.KeepAlive
+	if ka := client.KeepAliveSeconds(); ka > 0 {
+		peer["keepAlive"] = ka
 	}
 
 	settings := map[string]any{

+ 1 - 1
internal/sub/json_service_test.go

@@ -462,7 +462,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
 		Email:        "user",
 		PrivateKey:   clientPriv,
 		PreSharedKey: "psk-value",
-		KeepAlive:    25,
+		KeepAlive:    model.KeepAlivePtr(25),
 		AllowedIPs:   []string{"10.0.0.2/32", "fd00::2/128"},
 	}
 

+ 4 - 4
internal/sub/service.go

@@ -776,8 +776,8 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 	if client.PreSharedKey != "" {
 		params["presharedkey"] = client.PreSharedKey
 	}
-	if client.KeepAlive > 0 {
-		params["keepalive"] = strconv.Itoa(client.KeepAlive)
+	if ka := client.KeepAliveSeconds(); ka > 0 {
+		params["keepalive"] = strconv.Itoa(ka)
 	}
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
 }
@@ -878,8 +878,8 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
 	}
 	b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
 	fmt.Fprintf(&b, "Endpoint = %s:%d", host, port)
-	if client.KeepAlive > 0 {
-		fmt.Fprintf(&b, "\nPersistentKeepalive = %d", client.KeepAlive)
+	if ka := client.KeepAliveSeconds(); ka > 0 {
+		fmt.Fprintf(&b, "\nPersistentKeepalive = %d", ka)
 	}
 
 	return b.String()

+ 1 - 1
internal/sub/service_amneziawg_test.go

@@ -215,7 +215,7 @@ func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) {
 	server := &amneziawg.ServerSettings{PublicKey: "serverPub", PrimaryDNS: "8.8.8.8", MTU: 1420}
 
 	t.Run("every optional field set", func(t *testing.T) {
-		client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: 25}
+		client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: model.KeepAlivePtr(25)}
 		conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
 		if got := peerFields(t, conf); !slices.Equal(got, peerFieldOrder) {
 			t.Fatalf("peer fields = %v, want %v\n%s", got, peerFieldOrder, conf)

+ 2 - 2
internal/web/runtime/local.go

@@ -242,7 +242,7 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C
 		"publicKey":    client.PublicKey,
 		"allowedIPs":   client.AllowedIPs,
 		"preSharedKey": client.PreSharedKey,
-		"keepAlive":    wgKeepAlive(client.KeepAlive),
+		"keepAlive":    wgKeepAlive(client.KeepAliveSeconds()),
 	}
 	return l.AddUser(ctx, ib, user)
 }
@@ -283,7 +283,7 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri
 		"publicKey":    payload.PublicKey,
 		"allowedIPs":   payload.AllowedIPs,
 		"preSharedKey": payload.PreSharedKey,
-		"keepAlive":    wgKeepAlive(payload.KeepAlive),
+		"keepAlive":    wgKeepAlive(payload.KeepAliveSeconds()),
 	}
 	return l.AddUser(ctx, ib, user)
 }

+ 17 - 73
internal/web/service/client_bulk.go

@@ -344,36 +344,16 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 
 	addExpiryMs := int64(addDays) * 24 * 60 * 60 * 1000
 
-	seen := map[string]struct{}{}
-	cleanEmails := make([]string, 0, len(emails))
-	for _, e := range emails {
-		e = strings.TrimSpace(e)
-		if e == "" {
-			continue
-		}
-		if _, ok := seen[e]; ok {
-			continue
-		}
-		seen[e] = struct{}{}
-		cleanEmails = append(cleanEmails, e)
-	}
+	cleanEmails := trimmedUniqueEmails(emails)
 	if len(cleanEmails) == 0 {
 		return result, false, nil
 	}
 
 	db := database.GetDB()
 
-	var records []model.ClientRecord
-	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
-		var rows []model.ClientRecord
-		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
-			return result, false, err
-		}
-		records = append(records, rows...)
-	}
-	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
-	for i := range records {
-		recordsByEmail[records[i].Email] = &records[i]
+	recordsByEmail, err := clientRecordsByEmail(db, cleanEmails)
+	if err != nil {
+		return result, false, err
 	}
 
 	skippedReasons := map[string]string{}
@@ -894,38 +874,22 @@ type BulkDeleteReport struct {
 func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string, keepTraffic bool) (BulkDeleteResult, bool, error) {
 	result := BulkDeleteResult{}
 
-	seen := map[string]struct{}{}
-	cleanEmails := make([]string, 0, len(emails))
-	for _, e := range emails {
-		e = strings.TrimSpace(e)
-		if e == "" {
-			continue
-		}
-		if _, ok := seen[e]; ok {
-			continue
-		}
-		seen[e] = struct{}{}
-		cleanEmails = append(cleanEmails, e)
-	}
+	cleanEmails := trimmedUniqueEmails(emails)
 	if len(cleanEmails) == 0 {
 		return result, false, nil
 	}
 
 	db := database.GetDB()
 
-	var records []model.ClientRecord
-	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
-		var rows []model.ClientRecord
-		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
-			return result, false, err
-		}
-		records = append(records, rows...)
+	recordsByEmail, err := clientRecordsByEmail(db, cleanEmails)
+	if err != nil {
+		return result, false, err
 	}
-	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
-	tombstoneEmails := make([]string, 0, len(records))
-	for i := range records {
-		recordsByEmail[records[i].Email] = &records[i]
-		tombstoneEmails = append(tombstoneEmails, records[i].Email)
+	tombstoneEmails := make([]string, 0, len(recordsByEmail))
+	for _, email := range cleanEmails {
+		if recordsByEmail[email] != nil {
+			tombstoneEmails = append(tombstoneEmails, email)
+		}
 	}
 	tombstoneClientEmails(tombstoneEmails)
 
@@ -1579,36 +1543,16 @@ type BulkSetEnableReport struct {
 func (s *ClientService) BulkSetEnable(inboundSvc *InboundService, emails []string, enable bool) (BulkSetEnableResult, bool, error) {
 	result := BulkSetEnableResult{}
 
-	seen := map[string]struct{}{}
-	cleanEmails := make([]string, 0, len(emails))
-	for _, e := range emails {
-		e = strings.TrimSpace(e)
-		if e == "" {
-			continue
-		}
-		if _, ok := seen[e]; ok {
-			continue
-		}
-		seen[e] = struct{}{}
-		cleanEmails = append(cleanEmails, e)
-	}
+	cleanEmails := trimmedUniqueEmails(emails)
 	if len(cleanEmails) == 0 {
 		return result, false, nil
 	}
 
 	db := database.GetDB()
 
-	var records []model.ClientRecord
-	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
-		var rows []model.ClientRecord
-		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
-			return result, false, err
-		}
-		records = append(records, rows...)
-	}
-	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
-	for i := range records {
-		recordsByEmail[records[i].Email] = &records[i]
+	recordsByEmail, err := clientRecordsByEmail(db, cleanEmails)
+	if err != nil {
+		return result, false, err
 	}
 
 	skippedReasons := map[string]string{}

+ 87 - 0
internal/web/service/client_bulk_reset_query_count_test.go

@@ -0,0 +1,87 @@
+package service
+
+import (
+	"fmt"
+	"sync/atomic"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// countClientTableQueries runs fn with a callback counting SELECTs against the
+// clients table, so a per-email lookup shows up as growth with the batch size.
+func countClientTableQueries(t *testing.T, name string, fn func()) int {
+	t.Helper()
+	db := database.GetDB()
+	var n int64
+	cb := "test:count_clients_query_" + name
+	if err := db.Callback().Query().Before("gorm:query").Register(cb, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "clients" {
+			atomic.AddInt64(&n, 1)
+		}
+	}); err != nil {
+		t.Fatalf("register query callback: %v", err)
+	}
+	defer func() {
+		if err := db.Callback().Query().Remove(cb); err != nil {
+			t.Errorf("remove query callback: %v", err)
+		}
+	}()
+	fn()
+	return int(atomic.LoadInt64(&n))
+}
+
+func seedEnabledClientsForReset(t *testing.T, svc *ClientService, port int, n int, prefix string) []string {
+	t.Helper()
+	clients := make([]model.Client, 0, n)
+	for i := range n {
+		email := fmt.Sprintf("%s-%d@x", prefix, i)
+		clients = append(clients, model.Client{
+			Email:  email,
+			ID:     fmt.Sprintf("%08d-1111-1111-1111-111111111111", i),
+			SubID:  email,
+			Enable: true,
+		})
+	}
+	ib := mkInbound(t, port, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	emails := make([]string, 0, n)
+	for _, c := range clients {
+		mkTraffic(t, ib.Id, c.Email, 100, 200, 0, 0, true)
+		emails = append(emails, c.Email)
+	}
+	return emails
+}
+
+// TestBulkResetTraffic_DoesNotQueryPerEmail pins BulkResetTraffic's client
+// lookup to a batched read: the number of SELECTs on clients must not grow
+// with the number of emails reset.
+func TestBulkResetTraffic_DoesNotQueryPerEmail(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	few := seedEnabledClientsForReset(t, svc, 53010, 3, "few")
+	many := seedEnabledClientsForReset(t, svc, 53011, 30, "many")
+
+	fewCount := countClientTableQueries(t, "few", func() {
+		if _, err := svc.BulkResetTraffic(inboundSvc, few); err != nil {
+			t.Fatalf("BulkResetTraffic(few): %v", err)
+		}
+	})
+	manyCount := countClientTableQueries(t, "many", func() {
+		if _, err := svc.BulkResetTraffic(inboundSvc, many); err != nil {
+			t.Fatalf("BulkResetTraffic(many): %v", err)
+		}
+	})
+
+	if manyCount != fewCount {
+		t.Fatalf("clients SELECTs: %d emails -> %d, %d emails -> %d; want the same batched count",
+			len(few), fewCount, len(many), manyCount)
+	}
+}

+ 5 - 5
internal/web/service/client_inbound_apply.go

@@ -587,7 +587,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 					"publicKey":    client.PublicKey,
 					"allowedIPs":   client.AllowedIPs,
 					"preSharedKey": client.PreSharedKey,
-					"keepAlive":    keepAliveStr(client.KeepAlive),
+					"keepAlive":    keepAliveStr(client.KeepAliveSeconds()),
 				})
 				if err1 == nil {
 					logger.Debug("Client added on", rt.Name(), ":", client.Email)
@@ -734,7 +734,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		if clients[0].PreSharedKey == "" {
 			clients[0].PreSharedKey = old.PreSharedKey
 		}
-		if clients[0].KeepAlive == 0 {
+		if clients[0].KeepAlive == nil {
 			clients[0].KeepAlive = old.KeepAlive
 		}
 		// ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never
@@ -801,8 +801,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 				if clients[0].PreSharedKey != "" {
 					newMap["preSharedKey"] = clients[0].PreSharedKey
 				}
-				if clients[0].KeepAlive > 0 {
-					newMap["keepAlive"] = clients[0].KeepAlive
+				if ka := clients[0].KeepAliveSeconds(); ka > 0 {
+					newMap["keepAlive"] = ka
 				}
 				if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
 					newMap["forwardedPorts"] = clients[0].ForwardedPorts
@@ -1014,7 +1014,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 						"publicKey":    clients[0].PublicKey,
 						"allowedIPs":   clients[0].AllowedIPs,
 						"preSharedKey": clients[0].PreSharedKey,
-						"keepAlive":    keepAliveStr(clients[0].KeepAlive),
+						"keepAlive":    keepAliveStr(clients[0].KeepAliveSeconds()),
 					})
 					if err1 == nil {
 						logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)

+ 22 - 0
internal/web/service/client_lookup.go

@@ -270,3 +270,25 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
 	}
 	return out, nil
 }
+
+// clientRecordsByEmail batch-loads client rows for emails, keyed by email.
+// Callers pass an already-deduplicated list; absent addresses are simply
+// missing from the map.
+func clientRecordsByEmail(tx *gorm.DB, emails []string) (map[string]*model.ClientRecord, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	var records []model.ClientRecord
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := tx.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return nil, err
+		}
+		records = append(records, rows...)
+	}
+	byEmail := make(map[string]*model.ClientRecord, len(records))
+	for i := range records {
+		byEmail[records[i].Email] = &records[i]
+	}
+	return byEmail, nil
+}

+ 14 - 22
internal/web/service/client_traffic.go

@@ -1,7 +1,6 @@
 package service
 
 import (
-	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -60,36 +59,29 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 	if len(emails) == 0 {
 		return 0, nil
 	}
-	seen := map[string]struct{}{}
-	cleanEmails := make([]string, 0, len(emails))
-	for _, e := range emails {
-		e = strings.TrimSpace(e)
-		if e == "" {
-			continue
-		}
-		if _, ok := seen[e]; ok {
-			continue
-		}
-		seen[e] = struct{}{}
-		cleanEmails = append(cleanEmails, e)
-	}
+	cleanEmails := trimmedUniqueEmails(emails)
 	if len(cleanEmails) == 0 {
 		return 0, nil
 	}
 
+	recordsByEmail, err := clientRecordsByEmail(nil, cleanEmails)
+	if err != nil {
+		return 0, err
+	}
 	for _, e := range cleanEmails {
-		rec, err := s.GetRecordByEmail(nil, e)
-		if err == nil && !rec.Enable {
-			updated := rec.ToClient()
-			updated.Enable = true
-			if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
-				logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
-			}
+		rec := recordsByEmail[e]
+		if rec == nil || rec.Enable {
+			continue
+		}
+		updated := rec.ToClient()
+		updated.Enable = true
+		if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
+			logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
 		}
 	}
 
 	affected := 0
-	err := submitTrafficWrite(func() error {
+	err = submitTrafficWrite(func() error {
 		db := database.GetDB()
 		return db.Transaction(func(tx *gorm.DB) error {
 			if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {

+ 102 - 0
internal/web/service/client_update_keepalive_test.go

@@ -0,0 +1,102 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func inboundKeepAlive(t *testing.T, inboundSvc *InboundService, ibId int, email string) int {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(ibId)
+	if err != nil {
+		t.Fatalf("GetInbound %d: %v", ibId, err)
+	}
+	clients, err := inboundSvc.GetClients(ib)
+	if err != nil {
+		t.Fatalf("GetClients %d: %v", ibId, err)
+	}
+	for i := range clients {
+		if clients[i].Email == email {
+			return clients[i].KeepAliveSeconds()
+		}
+	}
+	t.Fatalf("email %q not found on inbound %d", email, ibId)
+	return 0
+}
+
+// seedKeepAliveClient attaches one WireGuard client already carrying a
+// PersistentKeepalive, and returns its inbound and client-record id.
+func seedKeepAliveClient(t *testing.T, email string, keepAlive int) (*model.Inbound, int) {
+	t.Helper()
+	svc := &ClientService{}
+
+	seeded := model.Client{
+		Email:      email,
+		SubID:      "sub-" + email,
+		Enable:     true,
+		AllowedIPs: []string{"10.0.0.5/32"},
+		KeepAlive:  model.KeepAlivePtr(keepAlive),
+	}
+	ib := mkInbound(t, 51820, model.WireGuard, clientsSettings(t, []model.Client{seeded}))
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{seeded}); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	return ib, lookupClientRecord(t, email).Id
+}
+
+// Carrying forward on a zero incoming keepalive was a 0 -> 0 no-op while no UI
+// could set the field; once the client form could, "0 disables it" was unreachable.
+func TestUpdateCanClearKeepAliveOnAnExistingClient(t *testing.T) {
+	setupBulkDB(t)
+	inboundSvc := &InboundService{}
+	svc := &ClientService{}
+
+	ib, recId := seedKeepAliveClient(t, "ka@x", 25)
+	if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 {
+		t.Fatalf("seeded keepAlive = %d, want 25", got)
+	}
+
+	updated := model.Client{
+		Email:      "ka@x",
+		Enable:     true,
+		AllowedIPs: []string{"10.0.0.5/32"},
+		KeepAlive:  model.KeepAlivePtr(0),
+	}
+	if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 0 {
+		t.Fatalf("inbound keepAlive after an explicit 0 = %d, want 0", got)
+	}
+	if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 0 {
+		t.Fatalf("stored wg_keep_alive after an explicit 0 = %d, want 0", got)
+	}
+}
+
+// The other half of the contract: a payload that never mentions keepAlive (a
+// metadata-only edit from the bot or the API) leaves the stored value alone.
+func TestUpdateWithoutKeepAlivePreservesTheStoredValue(t *testing.T) {
+	setupBulkDB(t)
+	inboundSvc := &InboundService{}
+	svc := &ClientService{}
+
+	ib, recId := seedKeepAliveClient(t, "ka@x", 25)
+
+	updated := model.Client{
+		Email:      "ka@x",
+		Enable:     true,
+		AllowedIPs: []string{"10.0.0.5/32"},
+	}
+	if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 {
+		t.Fatalf("inbound keepAlive after an edit that omitted it = %d, want 25", got)
+	}
+	if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 25 {
+		t.Fatalf("stored wg_keep_alive after an edit that omitted it = %d, want 25", got)
+	}
+}

+ 2 - 2
internal/web/service/client_wireguard.go

@@ -249,8 +249,8 @@ func defaultWireguardClients(settingsJSON string, existing, clients []model.Clie
 				if c.PreSharedKey != "" {
 					m["preSharedKey"] = c.PreSharedKey
 				}
-				if c.KeepAlive > 0 {
-					m["keepAlive"] = c.KeepAlive
+				if ka := c.KeepAliveSeconds(); ka > 0 {
+					m["keepAlive"] = ka
 				}
 				interfaceClients[i] = m
 			}

+ 12 - 0
internal/web/service/inbound_util.go

@@ -1,5 +1,7 @@
 package service
 
+import "strings"
+
 // sqliteMaxVars is a safe ceiling for the number of bind parameters in a
 // single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds
 // before 3.32 and 32766 after; staying under 999 keeps queries portable
@@ -38,6 +40,16 @@ func uniqueNonEmptyStrings(in []string) []string {
 	return out
 }
 
+// trimmedUniqueEmails trims each address before deduplicating, so the bulk
+// client operations treat " a@x " and "a@x" as the same row.
+func trimmedUniqueEmails(in []string) []string {
+	trimmed := make([]string, 0, len(in))
+	for _, e := range in {
+		trimmed = append(trimmed, strings.TrimSpace(e))
+	}
+	return uniqueNonEmptyStrings(trimmed)
+}
+
 // uniqueInts returns a deduplicated copy of in, preserving order of first occurrence.
 func uniqueInts(in []int) []int {
 	if len(in) == 0 {

+ 0 - 9
internal/web/service/setting.go

@@ -209,15 +209,6 @@ var defaultValueMap = map[string]string{
 // It handles configuration storage, retrieval, and validation for all system settings.
 type SettingService struct{}
 
-func (s *SettingService) GetDefaultJSONConfig() (any, error) {
-	var jsonData any
-	err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
-	if err != nil {
-		return nil, err
-	}
-	return jsonData, nil
-}
-
 func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
 	db := database.GetDB()
 	settings := make([]*model.Setting, 0)

+ 41 - 0
internal/web/service/tgbot/numeric_input.go

@@ -1,5 +1,12 @@
 package tgbot
 
+import (
+	"strconv"
+
+	"github.com/mymmrac/telego"
+	tu "github.com/mymmrac/telego/telegoutil"
+)
+
 // updateNumericInput applies one number-pad key: -2 clears, -1 backspaces, and 0..9 append.
 // Callers retain their own validation and keyboard labels.
 func updateNumericInput(value, key int) int {
@@ -15,3 +22,37 @@ func updateNumericInput(value, key int) int {
 		return value*10 + key
 	}
 }
+
+// numericKeypadSpec describes one number-pad flow. dataBase is the callback
+// prefix without its "_in"/"_c" suffix, and dataArgs carries any leading
+// argument (an email plus its separating space) the flow threads through.
+type numericKeypadSpec struct {
+	dataBase        string
+	dataArgs        string
+	cancelData      string
+	confirmLabelKey string
+}
+
+// numericKeypad builds the shared digit pad: cancel, confirm, 1-9, clear, 0 and
+// backspace. Every numeric callback flow renders the same grid, so the layout
+// and the callback wording live here rather than once per flow.
+func (t *Tgbot) numericKeypad(spec numericKeypadSpec, inputNumber int) *telego.InlineKeyboardMarkup {
+	value := strconv.Itoa(inputNumber)
+	key := func(label, k string) telego.InlineKeyboardButton {
+		return tu.InlineKeyboardButton(label).
+			WithCallbackData(t.encodeQuery(spec.dataBase + "_in " + spec.dataArgs + value + " " + k))
+	}
+	return tu.InlineKeyboard(
+		tu.InlineKeyboardRow(
+			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery(spec.cancelData)),
+		),
+		tu.InlineKeyboardRow(
+			tu.InlineKeyboardButton(t.I18nBot(spec.confirmLabelKey, "Num=="+value)).
+				WithCallbackData(t.encodeQuery(spec.dataBase+"_c "+spec.dataArgs+value)),
+		),
+		tu.InlineKeyboardRow(key("1", "1"), key("2", "2"), key("3", "3")),
+		tu.InlineKeyboardRow(key("4", "4"), key("5", "5"), key("6", "6")),
+		tu.InlineKeyboardRow(key("7", "7"), key("8", "8"), key("9", "9")),
+		tu.InlineKeyboardRow(key("🔄", "-2"), key("0", "0"), key("⬅️", "-1")),
+	)
+}

+ 6 - 168
internal/web/service/tgbot/tgbot_router.go

@@ -460,34 +460,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "limit_traffic", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}
@@ -521,34 +494,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_limit_traffic", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}
@@ -642,34 +588,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "reset_exp", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}
@@ -715,34 +634,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_reset_exp_c "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_reset_exp", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}
@@ -812,34 +704,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "ip_limit", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}
@@ -876,34 +741,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 								return
 							}
 						}
-						inlineKeyboard := tu.InlineKeyboard(
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_ip_limit_c "+strconv.Itoa(inputNumber))),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 1")),
-								tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 2")),
-								tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 3")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 4")),
-								tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 5")),
-								tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 6")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 7")),
-								tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 8")),
-								tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 9")),
-							),
-							tu.InlineKeyboardRow(
-								tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -2")),
-								tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 0")),
-								tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -1")),
-							),
-						)
+						inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_ip_limit", cancelData: "add_client_default_ip_limit", confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber)
 						t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
 						return
 					}

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

@@ -130,7 +130,7 @@ func wgPeerList(t *testing.T, settings map[string]any) []map[string]any {
 
 func TestGetXrayConfigWireGuardPeers(t *testing.T) {
 	clients := []model.Client{
-		{Email: "[email protected]", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25},
+		{Email: "[email protected]", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: model.KeepAlivePtr(25)},
 		{Email: "[email protected]", Enable: true, PublicKey: "pub-bob", AllowedIPs: []string{"10.0.0.3/32"}},
 	}
 	seedWGInbound(t, "wg-multi", 51820, clients)

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
       "amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها",
       "amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "إعدادات AmneziaWG",
       "mtprotoSecret": "سر MTProto",
       "mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",

+ 2 - 0
internal/web/translation/en-US.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
       "amneziaWgForwardedPorts": "Forwarded Ports",
       "amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG config",
       "mtprotoSecret": "MTProto secret",
       "mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
       "amneziaWgForwardedPorts": "Puertos reenviados",
       "amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Configuración de AmneziaWG",
       "mtprotoSecret": "Secreto MTProto",
       "mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
       "amneziaWgForwardedPorts": "پورت‌های هدایت‌شده",
       "amneziaWgForwardedPortsHint": "پورت‌ها/محدوده‌های DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "پیکربندی AmneziaWG",
       "mtprotoSecret": "سکرت MTProto",
       "mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
       "amneziaWgForwardedPorts": "Port yang Diteruskan",
       "amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Konfigurasi AmneziaWG",
       "mtprotoSecret": "Secret MTProto",
       "mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
       "amneziaWgForwardedPorts": "転送ポート",
       "amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 設定",
       "mtprotoSecret": "MTProto シークレット",
       "mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
       "amneziaWgForwardedPorts": "Portas encaminhadas",
       "amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Configuração do AmneziaWG",
       "mtprotoSecret": "Segredo MTProto",
       "mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
       "amneziaWgForwardedPorts": "Проброс портов",
       "amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
+      "tunnelKeepAlive": "Keepalive (секунды)",
+      "tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.",
       "amneziaWgConfig": "Конфиг AmneziaWG",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
       "amneziaWgForwardedPorts": "Yönlendirilen Portlar",
       "amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG Yapılandırması",
       "mtprotoSecret": "MTProto sırrı",
       "mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
       "amneziaWgForwardedPorts": "Перенаправлені порти",
       "amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Конфігурація AmneziaWG",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
       "amneziaWgForwardedPorts": "Cổng chuyển tiếp",
       "amneziaWgForwardedPortsHint": "Cổng/dải cổng được chuyển tiếp (DNAT) đến client này, vd. 80, 443, 8000-8100. Để trống nếu không cần.",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "Cấu hình AmneziaWG",
       "mtprotoSecret": "Secret MTProto",
       "mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
       "amneziaWgForwardedPorts": "转发端口",
       "amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 配置",
       "mtprotoSecret": "MTProto 密钥",
       "mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -879,6 +879,8 @@
       "amneziaWgAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
       "amneziaWgForwardedPorts": "轉發連接埠",
       "amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。",
+      "tunnelKeepAlive": "Keepalive (seconds)",
+      "tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
       "amneziaWgConfig": "AmneziaWG 設定",
       "mtprotoSecret": "MTProto 金鑰",
       "mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",