瀏覽代碼

feat(outbounds): support custom subscription user agents (#6398)

Some subscription providers require a client-specific User-Agent before returning outbound links. Persist an optional value per subscription and use it for refreshes and previews while preserving the existing default for blank values.
Timur Chernykh 16 小時之前
父節點
當前提交
9f07951ba7

+ 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)

+ 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": "分",