diff options
| author | Cody Robibero <cody@robibe.ro> | 2026-08-02 14:23:49 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-02 14:23:49 -0400 |
| commit | f865910a908ba36c5adfd7d6b394ec239077c31e (patch) | |
| tree | 0ea2102dc3764b92e75525634e3e954298a0ca8f /tests | |
| parent | f2360c07a37b29b6fbfd53cda019c8c3235adfc0 (diff) | |
| parent | 0c7428f13675d1b63234cdc3ef5c748eb998e8e9 (diff) | |
Merge pull request #17298 from WizardOfYendor1/fix/livetv-published-stream-urls
Fix Live TV returning unreachable "server-local" streaming URLs to clients.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs | 425 | ||||
| -rw-r--r-- | tests/Jellyfin.Networking.Tests/NetworkParseTests.cs | 215 |
2 files changed, 636 insertions, 4 deletions
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index a003be4d96..fe824eddd9 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -1,13 +1,21 @@ using System; using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers { public class MediaInfoHelperTests { - private static MediaInfoHelper CreateHelper() + private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts"; + + private static MediaInfoHelper CreateHelper( + IMediaSourceManager? mediaSourceManager = null, + IServerApplicationHost? appHost = null, + string baseUrl = "") { + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager + .Setup(x => x.GetConfiguration(It.IsAny<string>())) + .Returns(new NetworkConfiguration { BaseUrl = baseUrl }); + return new MediaInfoHelper( Mock.Of<IUserManager>(), Mock.Of<ILibraryManager>(), - Mock.Of<IMediaSourceManager>(), + mediaSourceManager ?? Mock.Of<IMediaSourceManager>(), Mock.Of<IMediaEncoder>(), - Mock.Of<IServerConfigurationManager>(), + serverConfigurationManager.Object, Mock.Of<ILogger<MediaInfoHelper>>(), Mock.Of<INetworkManager>(), - Mock.Of<IDeviceManager>()); + Mock.Of<IDeviceManager>(), + appHost ?? Mock.Of<IServerApplicationHost>()); } private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) @@ -95,5 +114,403 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(directPlay.Id, result.MediaSources[0].Id); } + + [Fact] + public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>(), liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path); + + // The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten. + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task OpenMediaSource_RewritesReturnedLiveStreamPath() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://127.0.0.1:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl() + { + const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath; + + // Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it + // and must not be able to affect it. + var localSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the + // helper mutates must be a deserialized copy, never localSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(localSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://jellyfin.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + + // The mock now actually derives its response from localSource, so this assertion is meaningful: + // rewriting the returned clone must never mutate the object localSource represents. + Assert.Equal(LocalPath, localSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged() + { + // A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/ + // route shape without actually being hosted by this server. Only opened streams (which always + // carry a LiveStreamId) are eligible for rewriting. + const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = ForeignPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(ForeignPath, response.MediaSource.Path); + } + + [Theory] + [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")] + [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")] + public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path) + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = protocol, + Path = path + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(path, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged() + { + const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(LocalPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort() + { + // Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API + // URL carries an explicit non-default port alongside the configured BaseUrl. + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + // Both requests resolve the same live stream; the manager hands back its own shared instance each time. + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var requestA = new DefaultHttpContext().Request; + var requestB = new DefaultHttpContext().Request; + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com"); + appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true); + var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path); + Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path); + + // Neither request's rewrite may leak into the other's response or into the shared instance. + Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path); + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath() + { + // Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246): + // it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then + // merges by replacing result.MediaSources with the opened source. Building a full controller fixture + // is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper + // calls the controller makes and asserts the merged source is the rewritten one. + var itemId = Guid.NewGuid(); + var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture); + + // The pre-open placeholder source carries a different local path than the one OpenMediaSource + // eventually returns, so the final assertion can prove the merge picked up the freshly opened + // source rather than the stale placeholder. + var requiresOpeningSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts", + RequiresOpening = true, + LiveStreamId = string.Empty + }; + + var openedSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetPlaybackMediaSources(It.IsAny<BaseItem>(), It.IsAny<User>(), true, true, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { requiresOpeningSource }); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning + // it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so + // the in-place rewrite below can't be observed on openedSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(openedSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>()).ConfigureAwait(true); + + var mediaSource = info.MediaSources[0]; + Assert.True(mediaSource.RequiresOpening); + var preOpenPath = mediaSource.Path; + + var openStreamResult = await helper.OpenMediaSource( + new DefaultHttpContext(), + new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true); + + // MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource }; + info.MediaSources = new[] { openStreamResult.MediaSource }; + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path); + Assert.NotEqual(preOpenPath, info.MediaSources[0].Path); + + // The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed. + Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path); + } + + [Theory] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath + "?token=1")] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "https://172.19.0.3:8920" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://192.168.1.10:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com:8920", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com:8920" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://jellyfin/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin/", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + null)] + [InlineData( + "https://media.example.com", + "/media/livetv/buffer/abc/stream.ts", + MediaProtocol.File, + "", + null)] + [InlineData( + "https://media.example.com", + "not a uri", + MediaProtocol.Http, + "", + null)] + public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected) + { + var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl); + + Assert.Equal(expected, result); + } + + private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "") + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new LiveStreamResponse(mediaSource)); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns(smartApiUrl); + + return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl); + } } } diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 1f523f7f21..d8cb9e1ac6 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -6,6 +6,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -493,5 +494,219 @@ namespace Jellyfin.Networking.Tests Assert.Equal(result, interfaceToUse); } + + [Theory] + // Internal override with an explicit port. + [InlineData("192.168.1.1", "192.168.1.0/24=internal.jellyfin:8097", "internal.jellyfin", 8097)] + // External/all override with an explicit port. + [InlineData("8.8.8.8", "all=external.jellyfin:8097", "external.jellyfin", 8097)] + // Bracketed IPv6 override with an explicit port. + [InlineData("8.8.8.8", "all=[fd00:1234::1]:8097", "fd00:1234::1", 8097)] + // Bare IPv6 override without a port - must remain whole, not mangled by the extra colons. + [InlineData("8.8.8.8", "all=fd00:1234::1", "fd00:1234::1", null)] + // Full HTTPS URL override with an explicit port - the URL stays whole, port stays embedded. + [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)] + // Hostname beginning with "http" is a hostname, not a URL scheme. + [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)] + // Literal "internal" keyword override (applies to every LAN subnet) with an explicit port. + [InlineData("192.168.1.1", "internal=myhost.internal:8097", "myhost.internal", 8097)] + // Literal "external" keyword override with an explicit port. + [InlineData("8.8.8.8", "external=myhost.external:9090", "myhost.external", 9090)] + public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var intf = nm.GetBindAddress(IPAddress.Parse(source), out int? port); + + Assert.Equal(expectedHost, intf); + Assert.Equal(expectedPort, port); + } + + /// <summary> + /// Regression coverage for <c>IServerApplicationHost.GetApiUrlForLocalAccess()</c>, which calls + /// <see cref="NetworkManager.GetBindAddress(IPAddress, out int?, bool)"/> with a null source address. + /// Published server URL overrides are only matched when a source address is supplied + /// (<c>MatchesPublishedServerUrl</c> requires it), so a null source must never come back as a published + /// CLI/dashboard URL - it must fall back to a plain local bind address. + /// </summary> + [Fact] + public void GetBindAddress_NullSource_DoesNotApplyPublishedServerOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "all=http://published.example.com" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var result = nm.GetBindAddress((IPAddress?)null, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Null(port); + } + + [Theory] + // Full-URL override with a different public path: warn about the Live TV fallback. + [InlineData("all=https://media.example.com", "/jellyfin", true)] + // Full-URL override that ends with the base URL (with and without a trailing slash): no warning. + [InlineData("all=https://media.example.com/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/jellyfin/", "/jellyfin", false)] + [InlineData("all=https://media.example.com/media/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/cool%20server", "/cool server", false)] + // A similar segment or a path following the base URL is a different public API base. + [InlineData("all=https://media.example.com/jellyfinx", "/jellyfin", true)] + [InlineData("all=https://media.example.com/jellyfin/media", "/jellyfin", true)] + // No base URL configured: there is no path to compare. + [InlineData("all=https://media.example.com", "", false)] + // Bare host overrides get the base URL appended when the API URL is built: no warning. + [InlineData("all=media.example.com", "/jellyfin", false)] + [InlineData("internal=http-proxy.lan:8097", "/jellyfin", false)] + // Keyword overrides go through the same check as "all". + [InlineData("internal=http://10.0.0.5:8096", "/jellyfin", true)] + public void InitializeOverrides_FullUrlPublicPathDiffersFromBaseUrl_LogsWarning(string publishedServers, string baseUrl, bool expectWarning) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers }, + BaseUrl = baseUrl + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never()); + } + + /// <summary> + /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the + /// startup-configuration branch of <c>InitializeOverrides</c> and must funnel through the same + /// base URL check as the dashboard overrides. + /// </summary> + [Fact] + public void InitializeOverrides_StartupPublishedServerUrlPathDiffersFromBaseUrl_LogsWarningWithoutCredentials() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + BaseUrl = "/jellyfin" + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + var startupConf = new Mock<IConfiguration>(); + startupConf.Setup(x => x[MediaBrowser.Controller.Extensions.ConfigurationExtensions.AddressOverrideKey]).Returns("https://user:password@media.example.com?access_token=secret#fragment"); + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, Times.AtLeastOnce()); + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("user", StringComparison.Ordinal) + || state.ToString()!.Contains("password", StringComparison.Ordinal) + || state.ToString()!.Contains("access_token", StringComparison.Ordinal) + || state.ToString()!.Contains("secret", StringComparison.Ordinal) + || state.ToString()!.Contains("fragment", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never()); + } + + private static void VerifyBaseUrlWarning(Mock<ILogger<NetworkManager>> logger, Times times) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + times); + } + + /// <summary> + /// <see cref="NetworkManager.GetBindAddress(HttpRequest, out int?)"/> is the piece of request-host + /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address + /// from the request's host and falls back to the request's own port when no override applies. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_FallsBackToRequestPortWhenNoOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Equal(34567, port); + } + + /// <summary> + /// Ordering check: a dashboard published-server-URL override's explicit port takes precedence over the + /// request's own port, even though the request's host chose which override subnet matched. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_PublishedOverridePortWinsOverRequestPort() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "internal=myhost.internal:9000" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("myhost.internal", result); + Assert.Equal(9000, port); + } } } |
