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.
This commit is contained in:
Timur Chernykh
2026-09-11 16:32:04 +03:00
committed by GitHub
parent 89ee1242bd
commit 9f07951ba7
22 changed files with 134 additions and 9 deletions

View File

@@ -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."

View File

@@ -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."

View File

@@ -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)',

View File

@@ -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

View File

@@ -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) {

View File

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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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://00000000-0000-4000-8000-000000000000@1.1.1.1: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))

View File

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

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

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