fix: add Navidrome UserAgent in all outgoing requests (#6020)

* There has been a report about navidrome hitting listenbrainz hard
and the listenbrainz guys wanting to be able to distinguish navidrome

* feat: apply Navidrome User-Agent to all outgoing HTTP requests

Add utils/httpclient, a shared http.Client factory whose transport sets
the User-Agent header (Navidrome/{version} - https://github.com/navidrome)
on any request that does not already have one, and use it at every place
the server builds an HTTP client: Last.fm, ListenBrainz and Deezer agents
and auth routers, insights collector, backgrounds handler, and the plugin
host HTTP service. Plugin-set User-Agent values are preserved. The
per-request header lines from the previous commit are superseded by the
transport.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Rob Emery 2026-08-24 16:22:32 +01:00 • committed by GitHub
commit 3da2b590e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 146 additions and 24 deletions

View file

@ -0,0 +1,35 @@
// Package httpclient provides a shared http.Client factory that identifies
// Navidrome via the User-Agent header on all outgoing requests.
package httpclient
import (
"net/http"
"time"
"github.com/navidrome/navidrome/consts"
)
type uaTransport struct {
base http.RoundTripper
}
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if _, ok := req.Header["User-Agent"]; !ok {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", consts.HTTPUserAgent)
}
return t.base.RoundTrip(req)
}
// NewTransport wraps base (or http.DefaultTransport if nil) to set the
// Navidrome User-Agent on requests that don't have one.
func NewTransport(base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
return &uaTransport{base: base}
}
func New(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout, Transport: NewTransport(nil)}
}

View file

@ -0,0 +1,17 @@
package httpclient_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHTTPClient(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "HTTPClient Suite")
}

View file

@ -0,0 +1,76 @@
package httpclient_test
import (
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("httpclient", func() {
var server *httptest.Server
var receivedUA string
BeforeEach(func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedUA = r.Header.Get("User-Agent")
}))
DeferCleanup(server.Close)
})
Describe("New", func() {
It("sets the Navidrome User-Agent when the request has none", func() {
c := httpclient.New(time.Second)
resp, err := c.Get(server.URL)
Expect(err).ToNot(HaveOccurred())
resp.Body.Close()
Expect(receivedUA).To(Equal(consts.HTTPUserAgent))
})
It("keeps a User-Agent already set by the caller", func() {
c := httpclient.New(time.Second)
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
Expect(err).ToNot(HaveOccurred())
req.Header.Set("User-Agent", "CustomAgent/1.0")
resp, err := c.Do(req)
Expect(err).ToNot(HaveOccurred())
resp.Body.Close()
Expect(receivedUA).To(Equal("CustomAgent/1.0"))
})
It("applies the given timeout", func() {
c := httpclient.New(5 * time.Second)
Expect(c.Timeout).To(Equal(5 * time.Second))
})
})
Describe("NewTransport", func() {
It("uses the default transport when base is nil", func() {
c := &http.Client{Transport: httpclient.NewTransport(nil)}
resp, err := c.Get(server.URL)
Expect(err).ToNot(HaveOccurred())
resp.Body.Close()
Expect(receivedUA).To(Equal(consts.HTTPUserAgent))
})
It("does not modify the original request", func() {
c := &http.Client{Transport: httpclient.NewTransport(nil)}
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
Expect(err).ToNot(HaveOccurred())
resp, err := c.Do(req)
Expect(err).ToNot(HaveOccurred())
resp.Body.Close()
Expect(req.Header).ToNot(HaveKey("User-Agent"))
})
})
Describe("HTTPUserAgent", func() {
It("identifies Navidrome with version and project URL", func() {
Expect(consts.HTTPUserAgent).To(Equal("Navidrome/" + consts.Version + " - https://github.com/navidrome"))
})
})
})