From 33043ccf7e30d9248be4f64529634852b79d00c5 Mon Sep 17 00:00:00 2001 From: Mathias Zhang Date: Tue, 17 Feb 2026 19:54:02 +0800 Subject: [PATCH] feat: support custom User-Agent for subscriptions --- cmd/run.go | 4 +-- common/subscription/subscription.go | 41 +++++++++++++++++++++++++++-- config/config.go | 1 + config/desc.go | 1 + example.dae | 9 +++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index a25e897e02..f94a3b81f1 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -263,7 +263,7 @@ loop: if err := c.StopDNSListener(); err != nil { log.Warnf("[Reload] Failed to stop old DNS listener: %v", err) } - + log.Warnln("[Reload] Load new control plane") newC, err := newControlPlane(log, obj, dnsCache, newConf, externGeoDataDirs) if err != nil { @@ -405,7 +405,7 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c Timeout: 30 * time.Second, } for _, sub := range conf.Subscription { - tag, nodes, err := subscription.ResolveSubscription(log, &client, filepath.Dir(cfgFile), string(sub)) + tag, nodes, err := subscription.ResolveSubscription(log, &client, filepath.Dir(cfgFile), string(sub), conf.Global.SubscriptionUserAgent) if err != nil { log.Warnf(`failed to resolve subscription "%v": %v`, sub, err) resolvingfailed = true diff --git a/common/subscription/subscription.go b/common/subscription/subscription.go index 6f76fcbe46..e432aa8a31 100644 --- a/common/subscription/subscription.go +++ b/common/subscription/subscription.go @@ -139,7 +139,24 @@ func ResolveFile(u *url.URL, configDir string) (b []byte, err error) { return bytes.TrimSpace(b), err } -func ResolveSubscription(log *logrus.Logger, client *http.Client, configDir string, subscription string) (tag string, nodes []string, err error) { +// parseUAFromFragment extracts the ua parameter from URL fragment. +// URL fragment format: #ua=xxx or #ua=xxx&other=yyy +// The value is URL-decoded to handle encoded characters (e.g., %20 for space). +func parseUAFromFragment(fragment string) string { + for _, part := range strings.Split(fragment, "&") { + if strings.HasPrefix(part, "ua=") { + ua := strings.TrimPrefix(part, "ua=") + // URL decode the value to handle encoded characters + if decoded, err := url.QueryUnescape(ua); err == nil { + return decoded + } + return ua + } + } + return "" +} + +func ResolveSubscription(log *logrus.Logger, client *http.Client, configDir string, subscription string, globalUA string) (tag string, nodes []string, err error) { /// Get tag. tag, subscription = common.GetTagFromLinkLikePlaintext(subscription) @@ -149,6 +166,24 @@ func ResolveSubscription(log *logrus.Logger, client *http.Client, configDir stri return tag, nil, fmt.Errorf("failed to parse subscription \"%v\": %w", subscription, err) } log.Debugf("ResolveSubscription: %v", subscription) + + // Extract per-subscription UA from fragment before clearing it + subscriptionUA := parseUAFromFragment(u.Fragment) + + // Determine User-Agent: subscription-level > global > default + defaultUA := fmt.Sprintf("dae/%v (like v2rayA/1.0 WebRequestHelper) (like v2rayN/1.0 WebRequestHelper)", config.Version) + userAgent := defaultUA + if globalUA != "" { + userAgent = globalUA + } + if subscriptionUA != "" { + userAgent = subscriptionUA + } + + // Clear fragment from URL after extracting UA (fragment should not be sent to server) + u.Fragment = "" + subscription = u.String() + var ( b []byte req *http.Request @@ -173,11 +208,13 @@ func ResolveSubscription(log *logrus.Logger, client *http.Client, configDir stri break default: } + req, err = http.NewRequest("GET", subscription, nil) if err != nil { return "", nil, err } - req.Header.Set("User-Agent", fmt.Sprintf("dae/%v (like v2rayA/1.0 WebRequestHelper) (like v2rayN/1.0 WebRequestHelper)", config.Version)) + + req.Header.Set("User-Agent", userAgent) resp, err = client.Do(req) if err != nil { if persistToFile { diff --git a/config/config.go b/config/config.go index c65fb0abb3..9be5cc317a 100644 --- a/config/config.go +++ b/config/config.go @@ -51,6 +51,7 @@ type Global struct { BandwidthMaxTx string `mapstructure:"bandwidth_max_tx" default:"0"` BandwidthMaxRx string `mapstructure:"bandwidth_max_rx" default:"0"` UDPHopInterval time.Duration `mapstructure:"udphop_interval" default:"30s"` + SubscriptionUserAgent string `mapstructure:"subscription_user_agent"` } type Utls struct { diff --git a/config/desc.go b/config/desc.go index 5720506c30..2c6eab1455 100644 --- a/config/desc.go +++ b/config/desc.go @@ -58,6 +58,7 @@ var GlobalDesc = Desc{ "tls_implementation": "TLS implementation. \"tls\" is to use Go's crypto/tls. \"utls\" is to use uTLS, which can imitate browser's Client Hello.", "utls_imitate": "The Client Hello ID for uTLS to imitate. This takes effect only if tls_implementation is utls. See more: https://github.com/daeuniverse/dae/blob/331fa23c16/component/outbound/transport/tls/utls.go#L17", "mptcp": "Enable Multipath TCP. If is true, dae will try to use MPTCP to connect all nodes, but it will only take effects when the node supports MPTCP. It can use for load balance and failover to multiple interfaces and IPs.", + "subscription_user_agent": "Custom User-Agent for fetching subscriptions. If not set, dae will use its default User-Agent. Per-subscription User-Agent can be set via URL fragment (e.g., https://example.com/sub#ua=V2rayA).", } var DnsDesc = Desc{ diff --git a/example.dae b/example.dae index f80fee4a39..eb9a157e5d 100644 --- a/example.dae +++ b/example.dae @@ -138,6 +138,12 @@ global { # For IPv6 DNS servers, the format is "[ipv6]:port", such as "[2001:4860:4860::8888]:53". # Fallback resolver will always be available, even if you do not specify it in the configuration file. # fallback_resolver: '8.8.8.8:53' + + # Custom User-Agent for fetching subscriptions. If not set, dae will use its default User-Agent. + # Some subscription providers return different node formats based on User-Agent. + # You can also set per-subscription User-Agent via URL fragment (e.g., https://example.com/sub#ua=V2rayA). + # Per-subscription User-Agent takes priority over this global setting. + # subscription_user_agent: 'V2rayA/2.2.0' } # Subscriptions defined here will be resolved as nodes and merged as a part of the global node pool. @@ -153,6 +159,9 @@ subscription { # This file will serve as a fallback when fetching the subscription via a link fails. # It will be updated automatically once the fetch is successful. persist_sub: 'https-file://www.example.com/persist_sub/link' + + # Per-subscription User-Agent via URL fragment (overrides global subscription_user_agent). + # sub_with_ua: 'https://example.com/sub#ua=V2rayA/2.2.0' } # Nodes defined here will be merged as a part of the global node pool.