2 Angajamente 89ee1242bd ... 0a2cd789ba

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  Lucas 0a2cd789ba fix(sub): enable ML-KEM for Mihomo REALITY subscriptions (#6451) 17 ore în urmă
  Timur Chernykh 9f07951ba7 feat(outbounds): support custom subscription user agents (#6398) 17 ore în urmă

+ 15 - 7
docs/content/docs/en/config/reality.mdx

@@ -118,13 +118,21 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **Leaked private key.** Only ever distribute the **public** key to clients.
 - **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
   the inbound client entry and the share link.
-- **Old client cores rejected by default.** An empty **Min Client Ver** is not
-  "no limit": Xray-core falls back to the built-in minimum of the core build you
-  run (26.3.27 in current releases) that keeps client TLS fingerprints fresh, so
-  third-party cores such as Mihomo and sing-box fail REALITY verification even
-  with a correct config — clients see timeouts while only Xray-core based apps
-  connect. Set it to `1.0.0` only if you must support them; that also re-admits
-  outdated fingerprints.
+- **Client version limits.** Xray-core v26.9.8+ no longer sets a minimum when
+  **Min Client Ver** is empty. An explicitly saved minimum still applies. Earlier
+  builds may use a built-in minimum (such as `26.3.27`), rejecting third-party
+  clients even with correct keys. Check the running core version before changing
+  this gate; lowering it also admits older fingerprints.
+- **Mihomo and ML-KEM.** Xray-core v26.9.8+ independently requires an
+  `X25519MLKEM768` key share before the optional `X25519` share. The Clash/Mihomo
+  YAML subscription enables `reality-opts.support-x25519mlkem768` for REALITY
+  nodes, including external links, and uses `chrome` when no fingerprint was set.
+  Explicit fingerprints are preserved: choose one that offers ML-KEM (`chrome`
+  with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
+  Raw `vless://` links do not carry this Mihomo option, so clients importing them
+  directly still need a persistent override. Very old REALITY servers that reject
+  ML-KEM require a per-node client override setting this option to `false`, or a
+  server upgrade. Clearing the version limit alone does not fix the handshake.
 
 </Callout>
 

+ 23 - 7
docs/content/docs/fa/config/reality.mdx

@@ -118,13 +118,29 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینت‌ها توزیع کنید.
 - **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
   مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد.
-- **هسته‌های قدیمی کلاینت به‌طور پیش‌فرض رد می‌شوند.** خالی گذاشتن
-  **حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
-  نسخهٔ هسته‌ای که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) بازمی‌گردد تا اثر انگشت‌های TLS کلاینت‌ها تازه
-  بمانند؛ در نتیجه هسته‌های شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
-  کاملاً درست در تأیید REALITY شکست می‌خورند — کلاینت‌ها تایم‌اوت می‌بینند و فقط
-  اپلیکیشن‌های مبتنی بر Xray-core وصل می‌شوند. تنها در صورت نیاز به پشتیبانی از
-  آن‌ها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشت‌های قدیمی را هم می‌پذیرد.
+- **محدودیت نسخهٔ کلاینت.** از نسخهٔ
+  `Xray-core v26.9.8`
+  به بعد، خالی بودن **حداقل نسخه کلاینت** حد پایین پیش‌فرض ایجاد نمی‌کند؛ مقدار ذخیره‌شده همچنان اعمال می‌شود.
+  نسخه‌های قدیمی‌تر ممکن است حد داخلی مانند
+  `26.3.27`
+  داشته باشند. ابتدا نسخهٔ هستهٔ در حال اجرا را بررسی کنید؛ کاهش حد، اثر انگشت‌های قدیمی را نیز مجاز می‌کند.
+- **Mihomo و ML-KEM.** هستهٔ جدید مستقل از محدودیت نسخه، وجود
+  `X25519MLKEM768`
+  را پیش از کلید اختیاری
+  `X25519`
+  لازم می‌داند. اشتراک YAML برای REALITY، از جمله لینک‌های خارجی، گزینهٔ
+  `reality-opts.support-x25519mlkem768`
+  را فعال می‌کند و در نبود اثر انگشت از
+  `chrome`
+  استفاده می‌کند. انتخاب صریح حفظ می‌شود و باید از ML-KEM پشتیبانی کند؛ برای
+  `uTLS v1.8.7`
+  در Mihomo از Chrome استفاده کنید. فعال کردن گزینه، اثر انگشت قدیمی را ارتقا نمی‌دهد.
+  لینک خام
+  `vless://`
+  این گزینه را منتقل نمی‌کند و هنگام ورود مستقیم، بازنویسی پایدار در کلاینت لازم است.
+  برای سرورهای بسیار قدیمی که ML-KEM را رد می‌کنند، گزینه را برای همان گره در کلاینت روی
+  `false`
+  بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند.
 
 </Callout>
 

+ 15 - 8
docs/content/docs/ru/config/reality.mdx

@@ -123,14 +123,21 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
   ключ.
 - **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
   как в записи клиента входящего подключения, так и в ссылке для подключения.
-- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
-  **Мин. версия клиента** не означает «без ограничений»: Xray-core использует
-  встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
-  который поддерживает свежесть
-  TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
-  не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
-  таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
-  только если они вам необходимы; это также допустит устаревшие отпечатки.
+- **Ограничения версии клиента.** В Xray-core v26.9.8+ пустое поле
+  **Мин. версия клиента** не задаёт нижнюю границу. Явно сохранённое ограничение
+  продолжает действовать. Более ранние сборки могут использовать встроенный
+  минимум (например, `26.3.27`) и отклонять сторонние клиенты с правильными ключами.
+  Проверьте версию работающего ядра: снижение ограничения допускает старые отпечатки.
+- **Mihomo и ML-KEM.** Xray-core v26.9.8+ отдельно требует ключ
+  `X25519MLKEM768` перед необязательным `X25519`. YAML-подписка Clash/Mihomo
+  включает `reality-opts.support-x25519mlkem768` для REALITY, в том числе внешних
+  ссылок, и выбирает `chrome`, если отпечаток не задан. Явный выбор сохраняется:
+  нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
+  обновляет старые отпечатки. Исходные ссылки `vless://` не передают эту настройку
+  Mihomo; при прямом импорте нужно постоянное переопределение в клиенте. Для очень
+  старых серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего
+  узла в клиенте или обновите сервер. Снятие ограничения версии не исправляет
+  это рукопожатие.
 
 </Callout>
 

+ 2 - 1
docs/content/docs/zh/config/reality.mdx

@@ -105,7 +105,8 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
 - **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
 - **私钥泄露。** 永远只把**公钥**分发给客户端。
 - **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
-- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
+- **客户端版本限制。** Xray-core v26.9.8+ 在**最小客户端版本**留空时不再设置默认下限,但已明确保存的限制仍生效。较早的内核可能使用内置下限(如 `26.3.27`),导致第三方客户端即使密钥正确也被拒绝。修改前先核对运行中的内核版本;降低限制也会放行较旧的指纹。
+- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接不携带这个 Mihomo 配置项,直接导入时仍需持久覆写。对拒绝 ML-KEM 的很旧的 REALITY 服务端,需在客户端按节点将此项覆写为 `false`,或升级服务端。仅清空版本限制无法解决握手问题。
 
 </Callout>
 

+ 14 - 0
docs/public/openapi.json

@@ -13569,6 +13569,11 @@
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "default": "3x-ui-outbound-sub/1.0",
+                    "description": "Custom User-Agent sent when fetching this subscription. Defaults to \"3x-ui-outbound-sub/1.0\"."
+                  },
                   "updateInterval": {
                     "type": "integer",
                     "default": 600,
@@ -13662,6 +13667,11 @@
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "default": "3x-ui-outbound-sub/1.0",
+                    "description": "Custom User-Agent sent when fetching this subscription. Defaults to \"3x-ui-outbound-sub/1.0\"."
+                  },
                   "updateInterval": {
                     "type": "integer",
                     "default": 600,
@@ -13914,6 +13924,10 @@
                     "type": "string",
                     "description": "Subscription URL to preview (required)."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "description": "Custom User-Agent sent while fetching the preview."
+                  },
                   "allowPrivate": {
                     "type": "boolean",
                     "description": "Allow a private/internal/loopback URL. Default false."

+ 14 - 0
frontend/public/openapi.json

@@ -13569,6 +13569,11 @@
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "default": "3x-ui-outbound-sub/1.0",
+                    "description": "Custom User-Agent sent when fetching this subscription. Defaults to \"3x-ui-outbound-sub/1.0\"."
+                  },
                   "updateInterval": {
                     "type": "integer",
                     "default": 600,
@@ -13662,6 +13667,11 @@
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "default": "3x-ui-outbound-sub/1.0",
+                    "description": "Custom User-Agent sent when fetching this subscription. Defaults to \"3x-ui-outbound-sub/1.0\"."
+                  },
                   "updateInterval": {
                     "type": "integer",
                     "default": 600,
@@ -13914,6 +13924,10 @@
                     "type": "string",
                     "description": "Subscription URL to preview (required)."
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "description": "Custom User-Agent sent while fetching the preview."
+                  },
                   "allowPrivate": {
                     "type": "boolean",
                     "description": "Allow a private/internal/loopback URL. Default false."

+ 15 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -90,6 +90,14 @@ const outboundSubscriptionBodyParams: EndpointParam[] = [
     desc: 'Prefix for generated outbound tags. Defaults to the lowest free "sub<N>-" prefix.',
     optional: true,
   },
+  {
+    name: 'userAgent',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Custom User-Agent sent when fetching this subscription. Defaults to "3x-ui-outbound-sub/1.0".',
+    optional: true,
+    defaultValue: '3x-ui-outbound-sub/1.0',
+  },
   {
     name: 'updateInterval',
     in: 'body (form)',
@@ -2502,6 +2510,13 @@ export const sections: readonly Section[] = [
             type: 'string',
             desc: 'Subscription URL to preview (required).',
           },
+          {
+            name: 'userAgent',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Custom User-Agent sent while fetching the preview.',
+            optional: true,
+          },
           {
             name: 'allowPrivate',
             in: 'body (form)',

+ 21 - 1
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -62,6 +62,8 @@ import { useOutboundColumns } from './useOutboundColumns';
 import OutboundCardList from './OutboundCardList';
 import SubscriptionOutbounds from './SubscriptionOutbounds';
 
+const defaultOutboundSubscriptionUserAgent = '3x-ui-outbound-sub/1.0';
+
 interface OutboundSub {
   id: number;
   remark?: string;
@@ -69,6 +71,7 @@ interface OutboundSub {
   enabled?: boolean;
   allowPrivate?: boolean;
   allowInsecure?: boolean;
+  userAgent?: string;
   prepend?: boolean;
   priority?: number;
   tagPrefix?: string;
@@ -136,6 +139,7 @@ export default function OutboundsTab({
     remark: '',
     url: '',
     tagPrefix: '',
+    userAgent: '',
     updateInterval: 600,
     enabled: true,
     allowPrivate: false,
@@ -334,6 +338,7 @@ export default function OutboundsTab({
     remark?: string;
     url?: string;
     tagPrefix?: string;
+    userAgent?: string;
     updateInterval?: number;
     enabled?: boolean;
     allowPrivate?: boolean;
@@ -344,6 +349,7 @@ export default function OutboundsTab({
       remark: src.remark ?? '',
       url: src.url ?? '',
       tagPrefix: src.tagPrefix ?? '',
+      userAgent: src.userAgent ?? '',
       updateInterval: src.updateInterval ?? 600,
       enabled: src.enabled ?? true,
       allowPrivate: src.allowPrivate ?? false,
@@ -356,6 +362,7 @@ export default function OutboundsTab({
       remark: '',
       url: '',
       tagPrefix: '',
+      userAgent: '',
       updateInterval: 600,
       enabled: true,
       allowPrivate: false,
@@ -370,6 +377,7 @@ export default function OutboundsTab({
       remark: sub.remark ?? '',
       url: sub.url ?? '',
       tagPrefix: sub.tagPrefix ?? '',
+      userAgent: sub.userAgent ?? '',
       updateInterval: sub.updateInterval ?? 600,
       enabled: sub.enabled ?? true,
       allowPrivate: sub.allowPrivate ?? false,
@@ -423,7 +431,12 @@ export default function OutboundsTab({
     try {
       const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
         '/panel/api/xray/outbound-subs/parse',
-        { url: newSub.url, allowPrivate: newSub.allowPrivate },
+        {
+          url: newSub.url,
+          userAgent: newSub.userAgent,
+          allowPrivate: newSub.allowPrivate,
+          allowInsecure: newSub.allowInsecure,
+        },
       );
       if (r?.success && Array.isArray(r.obj)) {
         setPreviewData(r.obj);
@@ -721,6 +734,13 @@ export default function OutboundsTab({
                   placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
                 />
               </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.userAgent')}>
+                <Input
+                  value={newSub.userAgent}
+                  onChange={(e) => setNewSub({ ...newSub, userAgent: e.target.value })}
+                  placeholder={defaultOutboundSubscriptionUserAgent}
+                />
+              </Form.Item>
               <Form.Item label={t('pages.xray.outboundSub.interval')}>
                 <Space>
                   <InputNumber

+ 11 - 0
internal/database/db.go

@@ -96,10 +96,21 @@ func migrateClientTrafficLastSubFetchColumn() error {
 	return migrator.AddColumn(&xray.ClientTraffic{}, "LastSubFetch")
 }
 
+func migrateOutboundSubscriptionUserAgentColumn() error {
+	migrator := db.Migrator()
+	if !migrator.HasTable(&model.OutboundSubscription{}) || migrator.HasColumn(&model.OutboundSubscription{}, "user_agent") {
+		return nil
+	}
+	return migrator.AddColumn(&model.OutboundSubscription{}, "UserAgent")
+}
+
 func initModels() error {
 	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
 		return err
 	}
+	if err := migrateOutboundSubscriptionUserAgentColumn(); err != nil {
+		return err
+	}
 	models := allModels()
 	for _, mdl := range models {
 		if IsPostgres() && postgresModelSettled(mdl) {

+ 1 - 0
internal/database/model/model.go

@@ -1248,6 +1248,7 @@ type OutboundSubscription struct {
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
 	AllowInsecure        bool   `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"`
+	UserAgent            string `json:"userAgent" form:"userAgent"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)

+ 3 - 0
internal/sub/clash_service.go

@@ -1072,8 +1072,11 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
 			realityOpts["short-id"] = shortID
 		}
 		if len(realityOpts) > 0 {
+			// Xray 26.9.8+ rejects REALITY handshakes without an ML-KEM key share.
+			realityOpts["support-x25519mlkem768"] = true
 			proxy["reality-opts"] = realityOpts
 		}
+		proxy["client-fingerprint"] = "chrome"
 		if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
 			proxy["client-fingerprint"] = fingerprint
 		}

+ 45 - 0
internal/sub/clash_service_test.go

@@ -161,6 +161,51 @@ func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
 	if opts["short-id"] != "ab12" {
 		t.Fatalf("short-id = %v, want ab12", opts["short-id"])
 	}
+	if opts["support-x25519mlkem768"] != true {
+		t.Fatalf("ML-KEM support = %v, want true", opts["support-x25519mlkem768"])
+	}
+}
+
+func TestClashRealityMLKEMAcrossSources(t *testing.T) {
+	svc := NewSubClashService(false, "", &SubService{})
+	for _, security := range []string{"reality", "tls", "none"} {
+		for _, fingerprint := range []string{"", "chrome", "firefox"} {
+			t.Run(security+"/"+fingerprint, func(t *testing.T) {
+				inbound := &model.Inbound{Listen: "example.com", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+				client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
+				stream := svc.streamData(fmt.Sprintf(`{"network":"tcp","security":%q,"realitySettings":{"serverNames":["example.com"],"shortIds":["ab12"],"settings":{"publicKey":"PBKvalue","fingerprint":%q}}}`, security, fingerprint))
+				link := "vless://" + client.ID + "@example.com:443?type=tcp&security=" + security + "&sni=example.com&pbk=PBKvalue&sid=ab12&fp=" + fingerprint
+				for source, proxy := range map[string]map[string]any{
+					"inbound":  svc.buildProxy(svc.SubService, inbound, client, stream, nil),
+					"external": svc.clashProxyFromExternal(link, "external"),
+				} {
+					if proxy == nil {
+						t.Fatalf("%s: missing proxy", source)
+					}
+					opts, exists := proxy["reality-opts"].(map[string]any)
+					if security != "reality" {
+						if exists {
+							t.Fatalf("%s: REALITY options leaked into %s: %#v", source, security, opts)
+						}
+						continue
+					}
+					if opts["support-x25519mlkem768"] != true || opts["public-key"] != "PBKvalue" || opts["short-id"] != "ab12" {
+						t.Fatalf("%s: incorrect REALITY options: %#v", source, opts)
+					}
+					wantFingerprint := fingerprint
+					if wantFingerprint == "" {
+						wantFingerprint = "chrome"
+					}
+					if proxy["client-fingerprint"] != wantFingerprint {
+						t.Fatalf("%s: fingerprint = %v, want %s", source, proxy["client-fingerprint"], wantFingerprint)
+					}
+					if legacyClashProxy(proxy) != nil {
+						t.Fatalf("%s: REALITY must stay excluded from legacy Clash", source)
+					}
+				}
+			})
+		}
+	}
 }
 
 // TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):

+ 6 - 3
internal/web/controller/xray_setting.go

@@ -516,6 +516,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 	remark := c.PostForm("remark")
 	rawURL := c.PostForm("url")
 	prefix := c.PostForm("tagPrefix")
+	userAgent := c.PostForm("userAgent")
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
@@ -527,7 +528,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 			interval = v
 		}
 	}
-	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure)
+	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, userAgent, enabled, interval, allowPrivate, prepend, allowInsecure)
 	if err != nil {
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		return
@@ -545,6 +546,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 	remark := c.PostForm("remark")
 	rawURL := c.PostForm("url")
 	prefix := c.PostForm("tagPrefix")
+	userAgent := c.PostForm("userAgent")
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
@@ -556,7 +558,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 			interval = v
 		}
 	}
-	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure); err != nil {
+	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, userAgent, enabled, interval, allowPrivate, prepend, allowInsecure); err != nil {
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		return
 	}
@@ -624,12 +626,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
 	}
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
+	userAgent := c.PostForm("userAgent")
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	svc := service.OutboundSubscriptionService{}
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// temporarily create a disabled row, refresh it, then delete. Cleaner would
 	// be to expose a pure ParseURL on the service, but this keeps the surface small.
-	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false, allowInsecure)
+	tmp, err := svc.Create("preview", rawURL, "", userAgent, false, 600, allowPrivate, false, allowInsecure)
 	if err != nil {
 		jsonMsg(c, "Failed to preview subscription", err)
 		return

+ 11 - 3
internal/web/service/outbound_subscription.go

@@ -58,6 +58,8 @@ func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []stri
 // subscription may aggregate many upstream outbounds into one document.
 const maxOutboundSubscriptionBytes int64 = 8 << 20
 
+const defaultOutboundSubscriptionUserAgent = "3x-ui-outbound-sub/1.0"
+
 var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit")
 
 func readBoundedOutboundSubscriptionBody(r io.Reader) ([]byte, error) {
@@ -164,7 +166,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (strin
 	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId)), nil
 }
 
-func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
+func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	if err != nil {
 		return nil, common.NewError("invalid subscription URL:", err)
@@ -193,6 +195,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 		Enabled:        enabled,
 		AllowPrivate:   allowPrivate,
 		AllowInsecure:  allowInsecure,
+		UserAgent:      strings.TrimSpace(userAgent),
 		Prepend:        prepend,
 		Priority:       int(count),
 		TagPrefix:      prefix,
@@ -205,7 +208,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 }
 
 // Update updates editable fields.
-func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) error {
+func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) error {
 	sub, err := s.Get(id)
 	if err != nil {
 		return err
@@ -232,6 +235,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	sub.Enabled = enabled
 	sub.AllowPrivate = allowPrivate
 	sub.AllowInsecure = allowInsecure
+	sub.UserAgent = strings.TrimSpace(userAgent)
 	sub.Prepend = prepend
 	sub.TagPrefix = prefix
 	sub.UpdateInterval = updateInterval
@@ -363,7 +367,11 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		s.recordError(sub, err)
 		return nil, err
 	}
-	req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
+	userAgent := strings.TrimSpace(sub.UserAgent)
+	if userAgent == "" {
+		userAgent = defaultOutboundSubscriptionUserAgent
+	}
+	req.Header.Set("User-Agent", userAgent)
 
 	resp, err := client.Do(req)
 	if err != nil {

+ 28 - 2
internal/web/service/outbound_subscription_test.go

@@ -3,6 +3,8 @@ package service
 import (
 	"bytes"
 	"errors"
+	"net/http"
+	"net/http/httptest"
 	"slices"
 	"testing"
 
@@ -40,7 +42,7 @@ func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testi
 		{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
-			created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, true, 600, false, false, false)
+			created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, "", true, 600, false, false, false)
 			if !errors.Is(err, errInjected) {
 				t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
 			}
@@ -83,7 +85,7 @@ func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t
 	})
 
 	err := (&OutboundSubscriptionService{}).Update(
-		original.Id, "after", "https://1.1.1.1/changed", "", false, 1200, false, false, false,
+		original.Id, "after", "https://1.1.1.1/changed", "", "", false, 1200, false, false, false,
 	)
 	if !errors.Is(err, errInjected) {
 		t.Fatalf("Update error = %v, want injected prefix query failure", err)
@@ -102,6 +104,30 @@ func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t
 	}
 }
 
+func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) {
+	setupSettingTestDB(t)
+	const wantUserAgent = "ClashMetaForAndroid/2.11.13"
+	var gotUserAgent string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotUserAgent = r.UserAgent()
+		_, _ = w.Write([]byte("vless://[email protected]:443?security=tls&type=tcp#node"))
+	}))
+	t.Cleanup(server.Close)
+
+	sub := &model.OutboundSubscription{
+		Url: server.URL, AllowPrivate: true, UserAgent: wantUserAgent, TagPrefix: "test-",
+	}
+	if err := database.GetDB().Create(sub).Error; err != nil {
+		t.Fatalf("seed subscription: %v", err)
+	}
+	if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil {
+		t.Fatalf("Refresh: %v", err)
+	}
+	if gotUserAgent != wantUserAgent {
+		t.Fatalf("User-Agent = %q, want %q", gotUserAgent, wantUserAgent)
+	}
+}
+
 func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (قائمة روابط بصيغة base64)",
         "tagPrefix": "بادئة الوسم",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "فاصل التحديث",
         "hours": "س",
         "minutes": "د",

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

@@ -1906,6 +1906,7 @@
         "urlPlaceholder": "https://... (base64 list of links)",
         "tagPrefix": "Tag prefix",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Update interval",
         "hours": "h",
         "minutes": "min",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (lista de enlaces en base64)",
         "tagPrefix": "Prefijo de etiqueta",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Intervalo de actualización",
         "hours": "h",
         "minutes": "min",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (فهرست base64 از لینک‌ها)",
         "tagPrefix": "پیشوند تگ",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "بازه به‌روزرسانی",
         "hours": "ساعت",
         "minutes": "دقیقه",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (daftar tautan base64)",
         "tagPrefix": "Awalan tag",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Interval pembaruan",
         "hours": "j",
         "minutes": "mnt",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://...(リンクのbase64リスト)",
         "tagPrefix": "タグのプレフィックス",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "更新間隔",
         "hours": "時間",
         "minutes": "分",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (lista de links em base64)",
         "tagPrefix": "Prefixo da tag",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Intervalo de atualização",
         "hours": "h",
         "minutes": "min",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (список ссылок в base64)",
         "tagPrefix": "Префикс тега",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Интервал обновления",
         "hours": "ч",
         "minutes": "мин",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (bağlantıların base64 listesi)",
         "tagPrefix": "Etiket öneki",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Güncelleme aralığı",
         "hours": "sa",
         "minutes": "dk",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (список посилань у base64)",
         "tagPrefix": "Префікс тегу",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Інтервал оновлення",
         "hours": "год",
         "minutes": "хв",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://... (danh sách liên kết base64)",
         "tagPrefix": "Tiền tố tag",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "Khoảng cập nhật",
         "hours": "giờ",
         "minutes": "phút",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://...(base64 编码的链接列表)",
         "tagPrefix": "标签前缀",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "更新间隔",
         "hours": "时",
         "minutes": "分",

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

@@ -1788,6 +1788,7 @@
         "urlPlaceholder": "https://...(base64 連結清單)",
         "tagPrefix": "標籤前綴",
         "tagPrefixPlaceholder": "hk-",
+        "userAgent": "User-Agent",
         "interval": "更新間隔",
         "hours": "時",
         "minutes": "分",