Przeglądaj źródła

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 godzin temu
rodzic
commit
9f07951ba7

+ 14 - 0
docs/public/openapi.json

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

+ 14 - 0
frontend/public/openapi.json

@@ -13569,6 +13569,11 @@
                     "type": "string",
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                     "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": {
                   "updateInterval": {
                     "type": "integer",
                     "type": "integer",
                     "default": 600,
                     "default": 600,
@@ -13662,6 +13667,11 @@
                     "type": "string",
                     "type": "string",
                     "description": "Prefix for generated outbound tags. Defaults to the lowest free \"sub<N>-\" prefix."
                     "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": {
                   "updateInterval": {
                     "type": "integer",
                     "type": "integer",
                     "default": 600,
                     "default": 600,
@@ -13914,6 +13924,10 @@
                     "type": "string",
                     "type": "string",
                     "description": "Subscription URL to preview (required)."
                     "description": "Subscription URL to preview (required)."
                   },
                   },
+                  "userAgent": {
+                    "type": "string",
+                    "description": "Custom User-Agent sent while fetching the preview."
+                  },
                   "allowPrivate": {
                   "allowPrivate": {
                     "type": "boolean",
                     "type": "boolean",
                     "description": "Allow a private/internal/loopback URL. Default false."
                     "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.',
     desc: 'Prefix for generated outbound tags. Defaults to the lowest free "sub<N>-" prefix.',
     optional: true,
     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',
     name: 'updateInterval',
     in: 'body (form)',
     in: 'body (form)',
@@ -2502,6 +2510,13 @@ export const sections: readonly Section[] = [
             type: 'string',
             type: 'string',
             desc: 'Subscription URL to preview (required).',
             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',
             name: 'allowPrivate',
             in: 'body (form)',
             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 OutboundCardList from './OutboundCardList';
 import SubscriptionOutbounds from './SubscriptionOutbounds';
 import SubscriptionOutbounds from './SubscriptionOutbounds';
 
 
+const defaultOutboundSubscriptionUserAgent = '3x-ui-outbound-sub/1.0';
+
 interface OutboundSub {
 interface OutboundSub {
   id: number;
   id: number;
   remark?: string;
   remark?: string;
@@ -69,6 +71,7 @@ interface OutboundSub {
   enabled?: boolean;
   enabled?: boolean;
   allowPrivate?: boolean;
   allowPrivate?: boolean;
   allowInsecure?: boolean;
   allowInsecure?: boolean;
+  userAgent?: string;
   prepend?: boolean;
   prepend?: boolean;
   priority?: number;
   priority?: number;
   tagPrefix?: string;
   tagPrefix?: string;
@@ -136,6 +139,7 @@ export default function OutboundsTab({
     remark: '',
     remark: '',
     url: '',
     url: '',
     tagPrefix: '',
     tagPrefix: '',
+    userAgent: '',
     updateInterval: 600,
     updateInterval: 600,
     enabled: true,
     enabled: true,
     allowPrivate: false,
     allowPrivate: false,
@@ -334,6 +338,7 @@ export default function OutboundsTab({
     remark?: string;
     remark?: string;
     url?: string;
     url?: string;
     tagPrefix?: string;
     tagPrefix?: string;
+    userAgent?: string;
     updateInterval?: number;
     updateInterval?: number;
     enabled?: boolean;
     enabled?: boolean;
     allowPrivate?: boolean;
     allowPrivate?: boolean;
@@ -344,6 +349,7 @@ export default function OutboundsTab({
       remark: src.remark ?? '',
       remark: src.remark ?? '',
       url: src.url ?? '',
       url: src.url ?? '',
       tagPrefix: src.tagPrefix ?? '',
       tagPrefix: src.tagPrefix ?? '',
+      userAgent: src.userAgent ?? '',
       updateInterval: src.updateInterval ?? 600,
       updateInterval: src.updateInterval ?? 600,
       enabled: src.enabled ?? true,
       enabled: src.enabled ?? true,
       allowPrivate: src.allowPrivate ?? false,
       allowPrivate: src.allowPrivate ?? false,
@@ -356,6 +362,7 @@ export default function OutboundsTab({
       remark: '',
       remark: '',
       url: '',
       url: '',
       tagPrefix: '',
       tagPrefix: '',
+      userAgent: '',
       updateInterval: 600,
       updateInterval: 600,
       enabled: true,
       enabled: true,
       allowPrivate: false,
       allowPrivate: false,
@@ -370,6 +377,7 @@ export default function OutboundsTab({
       remark: sub.remark ?? '',
       remark: sub.remark ?? '',
       url: sub.url ?? '',
       url: sub.url ?? '',
       tagPrefix: sub.tagPrefix ?? '',
       tagPrefix: sub.tagPrefix ?? '',
+      userAgent: sub.userAgent ?? '',
       updateInterval: sub.updateInterval ?? 600,
       updateInterval: sub.updateInterval ?? 600,
       enabled: sub.enabled ?? true,
       enabled: sub.enabled ?? true,
       allowPrivate: sub.allowPrivate ?? false,
       allowPrivate: sub.allowPrivate ?? false,
@@ -423,7 +431,12 @@ export default function OutboundsTab({
     try {
     try {
       const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
       const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
         '/panel/api/xray/outbound-subs/parse',
         '/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)) {
       if (r?.success && Array.isArray(r.obj)) {
         setPreviewData(r.obj);
         setPreviewData(r.obj);
@@ -721,6 +734,13 @@ export default function OutboundsTab({
                   placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
                   placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
                 />
                 />
               </Form.Item>
               </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')}>
               <Form.Item label={t('pages.xray.outboundSub.interval')}>
                 <Space>
                 <Space>
                   <InputNumber
                   <InputNumber

+ 11 - 0
internal/database/db.go

@@ -96,10 +96,21 @@ func migrateClientTrafficLastSubFetchColumn() error {
 	return migrator.AddColumn(&xray.ClientTraffic{}, "LastSubFetch")
 	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 {
 func initModels() error {
 	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
 	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
 		return err
 		return err
 	}
 	}
+	if err := migrateOutboundSubscriptionUserAgentColumn(); err != nil {
+		return err
+	}
 	models := allModels()
 	models := allModels()
 	for _, mdl := range models {
 	for _, mdl := range models {
 		if IsPostgres() && postgresModelSettled(mdl) {
 		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"`
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
 	AllowInsecure        bool   `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"`
 	AllowInsecure        bool   `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"`
+	UserAgent            string `json:"userAgent" form:"userAgent"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	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)
 	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")
 	remark := c.PostForm("remark")
 	rawURL := c.PostForm("url")
 	rawURL := c.PostForm("url")
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
+	userAgent := c.PostForm("userAgent")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
@@ -527,7 +528,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 			interval = v
 			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 {
 	if err != nil {
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		return
 		return
@@ -545,6 +546,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 	remark := c.PostForm("remark")
 	remark := c.PostForm("remark")
 	rawURL := c.PostForm("url")
 	rawURL := c.PostForm("url")
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
+	userAgent := c.PostForm("userAgent")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
@@ -556,7 +558,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 			interval = v
 			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)
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		return
 		return
 	}
 	}
@@ -624,12 +626,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
 	}
 	}
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
 	allowInsecure := c.PostForm("allowInsecure") == "true"
+	userAgent := c.PostForm("userAgent")
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	svc := service.OutboundSubscriptionService{}
 	svc := service.OutboundSubscriptionService{}
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// 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
 	// 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.
 	// 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 {
 	if err != nil {
 		jsonMsg(c, "Failed to preview subscription", err)
 		jsonMsg(c, "Failed to preview subscription", err)
 		return
 		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.
 // subscription may aggregate many upstream outbounds into one document.
 const maxOutboundSubscriptionBytes int64 = 8 << 20
 const maxOutboundSubscriptionBytes int64 = 8 << 20
 
 
+const defaultOutboundSubscriptionUserAgent = "3x-ui-outbound-sub/1.0"
+
 var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit")
 var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit")
 
 
 func readBoundedOutboundSubscriptionBody(r io.Reader) ([]byte, error) {
 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
 	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)
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	if err != nil {
 	if err != nil {
 		return nil, common.NewError("invalid subscription URL:", err)
 		return nil, common.NewError("invalid subscription URL:", err)
@@ -193,6 +195,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 		Enabled:        enabled,
 		Enabled:        enabled,
 		AllowPrivate:   allowPrivate,
 		AllowPrivate:   allowPrivate,
 		AllowInsecure:  allowInsecure,
 		AllowInsecure:  allowInsecure,
+		UserAgent:      strings.TrimSpace(userAgent),
 		Prepend:        prepend,
 		Prepend:        prepend,
 		Priority:       int(count),
 		Priority:       int(count),
 		TagPrefix:      prefix,
 		TagPrefix:      prefix,
@@ -205,7 +208,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 }
 }
 
 
 // Update updates editable fields.
 // 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)
 	sub, err := s.Get(id)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -232,6 +235,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	sub.Enabled = enabled
 	sub.Enabled = enabled
 	sub.AllowPrivate = allowPrivate
 	sub.AllowPrivate = allowPrivate
 	sub.AllowInsecure = allowInsecure
 	sub.AllowInsecure = allowInsecure
+	sub.UserAgent = strings.TrimSpace(userAgent)
 	sub.Prepend = prepend
 	sub.Prepend = prepend
 	sub.TagPrefix = prefix
 	sub.TagPrefix = prefix
 	sub.UpdateInterval = updateInterval
 	sub.UpdateInterval = updateInterval
@@ -363,7 +367,11 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		s.recordError(sub, err)
 		s.recordError(sub, err)
 		return nil, 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)
 	resp, err := client.Do(req)
 	if err != nil {
 	if err != nil {

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

@@ -3,6 +3,8 @@ package service
 import (
 import (
 	"bytes"
 	"bytes"
 	"errors"
 	"errors"
+	"net/http"
+	"net/http/httptest"
 	"slices"
 	"slices"
 	"testing"
 	"testing"
 
 
@@ -40,7 +42,7 @@ func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testi
 		{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
 		{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
 	} {
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 		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) {
 			if !errors.Is(err, errInjected) {
 				t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
 				t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
 			}
 			}
@@ -83,7 +85,7 @@ func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t
 	})
 	})
 
 
 	err := (&OutboundSubscriptionService{}).Update(
 	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) {
 	if !errors.Is(err, errInjected) {
 		t.Fatalf("Update error = %v, want injected prefix query failure", err)
 		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) {
 func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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