mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(jellyfin): add System/Endpoint so Finamp's connection test passes (#5955)
Finamp's connection test GETs /System/Endpoint and treats anything other than a 200 carrying an IsInNetwork key as "not a Jellyfin server", so the test failed against Navidrome and dual-connection setups could never switch to the local address. IsInNetwork mirrors Jellyfin's default LAN set (NetworkManager with no LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. Notably that set omits 169.254.0.0/16, so Go's IsLinkLocalUnicast is deliberately restricted to its IPv6 half. IsLocal mirrors HttpContext.IsLocal(): the caller shares the connection's local address, not merely "is loopback". It falls back to the loopback check when the local address is unavailable.
This commit is contained in:
parent
59a4ed8e79
commit
aa0824e03b
@ -112,7 +112,7 @@ returns direct children only (no tracks — no track is a library's direct child
|
||||
|
||||
| Area | Endpoints |
|
||||
|---|---|
|
||||
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` |
|
||||
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
|
||||
| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` |
|
||||
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
|
||||
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |
|
||||
|
||||
@ -104,6 +104,7 @@ func (api *Router) routes() http.Handler {
|
||||
// player) even before the first playback report.
|
||||
r.Use(api.withPlayer)
|
||||
r.Get("/system/info", api.getSystemInfo)
|
||||
r.Get("/system/endpoint", api.getEndpointInfo)
|
||||
r.Get("/userviews", api.getUserViews)
|
||||
r.Get("/users/{userId}/views", api.getUserViews)
|
||||
r.Get("/users/me", api.getCurrentUser)
|
||||
|
||||
@ -20,6 +20,12 @@ type SystemInfo struct {
|
||||
CachePath string `json:"CachePath,omitempty"`
|
||||
}
|
||||
|
||||
// EndPointInfo describes the caller's network location (GET /System/Endpoint).
|
||||
type EndPointInfo struct {
|
||||
IsLocal bool `json:"IsLocal"`
|
||||
IsInNetwork bool `json:"IsInNetwork"`
|
||||
}
|
||||
|
||||
type NameGuidPair struct {
|
||||
Name string `json:"Name"`
|
||||
Id string `json:"Id"`
|
||||
|
||||
@ -51,6 +51,22 @@ var _ = Describe("System", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /System/Endpoint", func() {
|
||||
It("always reports IsInNetwork, which Finamp's connection test probes for", func() {
|
||||
w := getAs(regularUser, "/System/Endpoint")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var info map[string]any
|
||||
parseInto(w, &info)
|
||||
Expect(info).To(HaveKey("IsInNetwork"))
|
||||
Expect(info).To(HaveKey("IsLocal"))
|
||||
})
|
||||
|
||||
It("rejects unauthenticated requests", func() {
|
||||
w := rawReq("GET", "/System/Endpoint", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET/POST /System/Ping", func() {
|
||||
It("answers GET with a plain-text server name", func() {
|
||||
w := rawReq("GET", "/System/Ping", "")
|
||||
|
||||
@ -5,7 +5,9 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
@ -108,6 +110,48 @@ func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(api.serverName()))
|
||||
}
|
||||
|
||||
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
|
||||
// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server".
|
||||
func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) {
|
||||
remote := remoteIP(r)
|
||||
api.ok(w, r, dto.EndPointInfo{
|
||||
IsLocal: isSameMachine(r, remote),
|
||||
IsInNetwork: isInLocalNetwork(remote),
|
||||
})
|
||||
}
|
||||
|
||||
// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no
|
||||
// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10.
|
||||
func isInLocalNetwork(ip netip.Addr) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast())
|
||||
}
|
||||
|
||||
// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local
|
||||
// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback.
|
||||
func isSameMachine(r *http.Request, remote netip.Addr) bool {
|
||||
local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
||||
if !ok {
|
||||
return remote.IsLoopback()
|
||||
}
|
||||
return parseIP(local.String()) == remote
|
||||
}
|
||||
|
||||
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
|
||||
func remoteIP(r *http.Request) netip.Addr {
|
||||
return parseIP(r.RemoteAddr)
|
||||
}
|
||||
|
||||
func parseIP(addr string) netip.Addr {
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||||
addr = h
|
||||
}
|
||||
ip, err := netip.ParseAddr(addr)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return ip.Unmap()
|
||||
}
|
||||
|
||||
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
api.ok(w, r, false)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
@ -88,6 +89,61 @@ var _ = Describe("System", func() {
|
||||
Expect(w.Body.String()).To(HavePrefix("Navidrome"))
|
||||
})
|
||||
|
||||
DescribeTable("reports the caller's network location on /System/Endpoint",
|
||||
func(remoteAddr string, isLocal, isInNetwork bool) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
||||
r.RemoteAddr = remoteAddr
|
||||
api.getEndpointInfo(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
// Finamp's connection test only probes for the key's presence, so it must always be emitted.
|
||||
Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`))
|
||||
var info dto.EndPointInfo
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
||||
Expect(info.IsLocal).To(Equal(isLocal))
|
||||
Expect(info.IsInNetwork).To(Equal(isInNetwork))
|
||||
},
|
||||
Entry("loopback", "127.0.0.1:12345", true, true),
|
||||
Entry("IPv6 loopback", "[::1]:12345", true, true),
|
||||
Entry("LAN address", "192.168.1.20:54321", false, true),
|
||||
Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true),
|
||||
Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true),
|
||||
Entry("IPv6 link-local", "[fe80::1]:80", false, true),
|
||||
Entry("IPv6 unique-local", "[fd00::1]:80", false, true),
|
||||
// Jellyfin's default LAN set omits 169.254.0.0/16, so we do too.
|
||||
Entry("IPv4 link-local", "169.254.1.1:80", false, false),
|
||||
Entry("public address", "8.8.8.8:443", false, false),
|
||||
Entry("unparseable address", "not-an-ip", false, false),
|
||||
)
|
||||
|
||||
It("reports IsLocal when the caller shares the connection's local address", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
||||
r.RemoteAddr = "192.168.1.20:54321"
|
||||
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
|
||||
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
|
||||
api.getEndpointInfo(w, r.WithContext(ctx))
|
||||
|
||||
var info dto.EndPointInfo
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
||||
Expect(info.IsLocal).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not report IsLocal for a different host on the same LAN", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
||||
r.RemoteAddr = "192.168.1.99:54321"
|
||||
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
|
||||
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
|
||||
api.getEndpointInfo(w, r.WithContext(ctx))
|
||||
|
||||
var info dto.EndPointInfo
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
||||
Expect(info.IsLocal).To(BeFalse())
|
||||
Expect(info.IsInNetwork).To(BeTrue())
|
||||
})
|
||||
|
||||
It("reports quick connect as disabled", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user