feat(sub): make external subscription fetch User-Agent configurable (#6613)

* feat(sub): make external subscription fetch User-Agent configurable

Some providers reject fetches that do not send a known client User-Agent.
Expose externalSubUserAgent as a panel setting (default v2rayNG/1.8.5)
and use it when fetching client external subscription URLs.

Fixes #6383

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* fix(sub): fall back to the default UA when the DB is not initialised

externalSubUserAgent read the setting through SettingService.getSetting,
which calls Model() on database.GetDB() and panics on a nil *gorm.DB.
The fetch path's other DB read, service.ExternalSubscriptionHwid, already
treats a nil DB as unreachable and sends no header; the new UA lookup
did not, so any fetch before InitDB panicked instead of sending the
historical v2rayNG/1.8.5.

Production initialises the DB before the sub server starts, but the
internal/sub fetch tests run without one: under make test-go's
-shuffle=on, whenever one of them ran before the first InitDB test the
panic aborted the whole package. Reproduced deterministically with
go test -run '^TestDoFetchSubscriptionLinks_RejectsOversizedBody$'.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-27 01:06:33 +03:30
committed by GitHub
parent 9672249edb
commit bd9ccde1f4
26 changed files with 161 additions and 1 deletions
+14 -1
View File
@@ -148,7 +148,7 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
return nil, err
}
// Some providers gate the link body on a known client User-Agent.
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
req.Header.Set("User-Agent", externalSubUserAgent())
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
req.Header.Set("X-HWID", hwid)
@@ -176,6 +176,19 @@ var (
errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
)
// externalSubUserAgent returns the panel setting for external subscription
// fetches, or the historical client UA when it is unset or the DB is unreachable.
func externalSubUserAgent() string {
if database.GetDB() == nil {
return service.DefaultExternalSubUserAgent
}
ua, err := (&service.SettingService{}).GetExternalSubUserAgent()
if err != nil {
return service.DefaultExternalSubUserAgent
}
return ua
}
type subError struct{ msg string }
func (e *subError) Error() string { return e.msg }
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -298,3 +299,57 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
t.Fatalf("cache hit wrote fetch status: %#v", after)
}
}
func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
resetSubscriptionCache(t)
if err := database.InitDB(filepath.Join(t.TempDir(), "ua.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const customUA = "Happ/4.2.1"
if err := database.GetDB().Create(&model.Setting{
Key: "externalSubUserAgent",
Value: customUA,
}).Error; err != nil {
t.Fatalf("save setting: %v", err)
}
var gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
}))
defer srv.Close()
res := fetchSubscriptionLinks(srv.URL)
if res.err != nil {
t.Fatalf("fetch: %v", res.err)
}
if gotUA != customUA {
t.Fatalf("User-Agent = %q, want %q", gotUA, customUA)
}
}
func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
resetSubscriptionCache(t)
if err := database.InitDB(filepath.Join(t.TempDir(), "ua-default.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
var gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
_, _ = w.Write([]byte("vless://uuid@host:443?security=none#x"))
}))
defer srv.Close()
res := fetchSubscriptionLinks(srv.URL)
if res.err != nil {
t.Fatalf("fetch: %v", res.err)
}
if gotUA != "v2rayNG/1.8.5" {
t.Fatalf("User-Agent = %q, want default v2rayNG/1.8.5", gotUA)
}
}