Compare commits

...

2 Commits

Author SHA1 Message Date
Deluan
cccbbfa723 refactor: use httpclient.New() for all external HTTP clients
Replace all direct &http.Client{} instantiations with httpclient.New()
across adapters (ListenBrainz, Last.fm, Spotify, Deezer), core services
(artwork, metrics), server (backgrounds), and plugins. This ensures all
outgoing HTTP connections use the configured TLS settings, disabling
post-quantum key exchange by default to prevent connection failures with
incompatible servers and proxies.
2026-02-25 13:22:01 -05:00
Deluan
b74db6c2c0 feat: add httpclient factory to disable post-quantum TLS by default
Add EnablePostQuantumTLS config flag (default: false) and a new
httpclient.New() factory function that configures HTTP clients with
classic TLS curve preferences (X25519, P-256, P-384) when post-quantum
is disabled. This avoids the larger ClientHello messages from Go 1.23+'s
Kyber/ML-KEM key exchange, which cause "connection reset by peer" errors
with servers like ListenBrainz and certain corporate proxies that can't
handle them. See golang/go#70139 and navidrome/navidrome#3886.
2026-02-25 13:15:07 -05:00
14 changed files with 140 additions and 28 deletions

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
@@ -13,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -34,9 +34,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
return agent

View File

@@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"golang.org/x/net/html"
)
@@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, chc)

View File

@@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/req"
)
@@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
r.client = newClient(r.apiKey, r.secret, hc)
return r
}

View File

@@ -3,7 +3,6 @@ package listenbrainz
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@@ -12,6 +11,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
baseURL: conf.Server.ListenBrainz.BaseURL,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l

View File

@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils/httpclient"
)
type sessionKeysRepo interface {
@@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
return r
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
@@ -14,6 +13,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/xrash/smetrics"
)
@@ -35,9 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
id: conf.Server.Spotify.ID,
secret: conf.Server.Spotify.Secret,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.id, l.secret, chc)
return l

View File

@@ -46,6 +46,7 @@ type configOptions struct {
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnablePostQuantumTLS bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
@@ -631,6 +632,7 @@ func setViperDefaults() {
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablepostquantumtls", false)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)

View File

@@ -23,6 +23,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils/httpclient"
"go.senan.xyz/taglib"
)
@@ -227,7 +228,7 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
}
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
hc := httpclient.New(5 * time.Second)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req) //nolint:gosec

View File

@@ -25,6 +25,7 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -94,9 +95,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
log.Trace(ctx, "No users found, skipping Insights data collection")
return
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
data := c.collect(ctx)
if data == nil {
return

View File

@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/httpclient"
)
const (
@@ -40,7 +41,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
requiredHosts: requiredHosts,
}
svc.client = &http.Client{
Transport: http.DefaultTransport,
Transport: httpclient.New(0).Transport,
// Timeout is set per-request via context deadline, not here.
// CheckRedirect validates hosts and enforces redirect limits.
CheckRedirect: func(req *http.Request, via []*http.Request) error {

View File

@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/random"
"gopkg.in/yaml.v3"
)
@@ -35,7 +36,7 @@ type Handler struct {
func NewHandler() *Handler {
h := &Handler{}
h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL)
h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL)
h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage)
go func() {
_, _ = h.getImageList(log.NewContext(context.Background()))
@@ -78,7 +79,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
if image == "" {
return nil, errors.New("empty image name")
}
c := http.Client{Timeout: imageRequestTimeout}
c := httpclient.New(imageRequestTimeout)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {

View File

@@ -0,0 +1,29 @@
package httpclient
import (
"crypto/tls"
"net/http"
"time"
"github.com/navidrome/navidrome/conf"
)
// New returns an HTTP client suitable for external service calls.
// When EnablePostQuantumTLS is false (the default), it disables post-quantum
// key exchange (Kyber/ML-KEM) to avoid "connection reset by peer" errors with
// servers that can't handle the larger TLS ClientHello.
// See https://github.com/golang/go/issues/70139
func New(timeout time.Duration) *http.Client {
if conf.Server.EnablePostQuantumTLS {
return &http.Client{Timeout: timeout}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}
}

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,71 @@
package httpclient_test
import (
"crypto/tls"
"net/http"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("New", func() {
When("EnablePostQuantumTLS is false (default)", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnablePostQuantumTLS = false
})
It("returns a client with classic curve preferences", func() {
client := httpclient.New(10 * time.Second)
Expect(client).ToNot(BeNil())
Expect(client.Timeout).To(Equal(10 * time.Second))
transport, ok := client.Transport.(*http.Transport)
Expect(ok).To(BeTrue())
Expect(transport.TLSClientConfig).ToNot(BeNil())
Expect(transport.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12)))
Expect(transport.TLSClientConfig.CurvePreferences).To(Equal([]tls.CurveID{
tls.X25519, tls.CurveP256, tls.CurveP384,
}))
})
It("does not modify http.DefaultTransport", func() {
_ = httpclient.New(5 * time.Second)
defaultTransport := http.DefaultTransport.(*http.Transport)
if defaultTransport.TLSClientConfig != nil {
Expect(defaultTransport.TLSClientConfig.CurvePreferences).To(BeNil())
}
})
})
When("EnablePostQuantumTLS is true", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnablePostQuantumTLS = true
})
It("returns a client with default transport (no custom TLS config)", func() {
client := httpclient.New(10 * time.Second)
Expect(client).ToNot(BeNil())
Expect(client.Timeout).To(Equal(10 * time.Second))
Expect(client.Transport).To(BeNil())
})
})
It("respects the timeout parameter", func() {
DeferCleanup(configtest.SetupConfig())
client := httpclient.New(30 * time.Second)
Expect(client.Timeout).To(Equal(30 * time.Second))
})
It("works with zero timeout", func() {
DeferCleanup(configtest.SetupConfig())
client := httpclient.New(0)
Expect(client.Timeout).To(Equal(time.Duration(0)))
})
})