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:
Deluan Quintão 2026-08-14 09:17:00 -04:00 committed by GitHub
parent 59a4ed8e79
commit aa0824e03b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 124 additions and 1 deletions

View File

@ -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) |

View File

@ -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)

View File

@ -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"`

View File

@ -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", "")

View File

@ -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)
}

View File

@ -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)