From ce43df6f43b851e7b09dd6b91ed52a3335feb7a2 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 10 Jul 2026 14:19:08 -0400 Subject: Fix host and port handling for published server URI overrides --- src/Jellyfin.Networking/Manager/NetworkManager.cs | 49 ++++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 4559f68ce8..69f36e9bb1 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -851,7 +851,7 @@ public class NetworkManager : INetworkManager, IDisposable bool isExternal = !IsInLocalNetwork(source); _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); - if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result, out port)) { return result; } @@ -1017,11 +1017,12 @@ public class NetworkManager : INetworkManager, IDisposable /// IP source address to use. /// True if the source is in an external subnet. /// The published server URL that matches the source address. + /// The explicit port parsed from the override, if any. /// true if a match is found, false otherwise. - private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference, out int? port) { bindPreference = string.Empty; - int? port = null; + port = null; // Only consider subnets including the source IP, preferring specific overrides List validPublishedServerUrls; @@ -1063,24 +1064,42 @@ public class NetworkManager : INetworkManager, IDisposable return false; } - // Handle override specifying port - var parts = bindPreference.Split(':'); - if (parts.Length > 1) + // Handle override specifying an explicit port. + (bindPreference, port) = ParseHostAndPort(bindPreference); + + if (port.HasValue) { - if (int.TryParse(parts[1], out int p)) - { - bindPreference = parts[0]; - port = p; - _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); - return true; - } + _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); + } + else + { + _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); } - - _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); return true; } + /// + /// Splits a published server URL override into its host and explicit port, if any. + /// Full URLs (containing "://") are returned whole, with any port left embedded. + /// + /// The override value, e.g. "host:port", "[::1]:port", or a full URL. + /// The parsed host (or the original value if not split) and the explicit port, if any. + private static (string Host, int? Port) ParseHostAndPort(string value) + { + if (value.Contains("://", StringComparison.Ordinal)) + { + return (value, null); + } + + if (Uri.TryCreate("any://" + value, UriKind.Absolute, out var parsed) && parsed.Port != -1) + { + return (parsed.DnsSafeHost, parsed.Port); + } + + return (value, null); + } + /// /// Attempts to match the source against the user defined bind interfaces. /// -- cgit v1.2.3 From 0c7428f13675d1b63234cdc3ef5c748eb998e8e9 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 17 Jul 2026 12:02:09 -0400 Subject: Added verbose, rambling, log warning to help users with config issues (hoping to reduce false issues reports). Also added a test to exercise it, which is perhaps silly but convenient. --- src/Jellyfin.Networking/Manager/NetworkManager.cs | 44 +++++++++++ .../Jellyfin.Networking.Tests/NetworkParseTests.cs | 89 ++++++++++++++++++++++ 2 files changed, 133 insertions(+) (limited to 'src') diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 69f36e9bb1..496c108cfd 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -491,6 +491,7 @@ public class NetworkManager : INetworkManager, IDisposable startupOverrideKey, true, true)); + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; return; } @@ -580,10 +581,53 @@ public class NetworkManager : INetworkManager, IDisposable } } + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; } } + /// + /// Warns when a full-URL published server override uses a public path that differs from the configured base + /// URL. Jellyfin appends the base URL to generated Live TV client URLs in this case, which can conflict with + /// reverse proxies that translate public request paths. Bare host/IP overrides are exempt because the base URL + /// is appended when the API URL is built from them. + /// + /// The parsed published server URL overrides. + /// The configured base URL, if any. + private void WarnIfPublishedUrlBasePathDiffers(List publishedServerUrls, string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + return; + } + + foreach (var overrideUri in publishedServerUrls.Select(x => x.OverrideUri).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!overrideUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !overrideUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!Uri.TryCreate(overrideUri, UriKind.Absolute, out var uri)) + { + continue; + } + + var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimEnd('/'); + if (path.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var publishedServerHost = uri.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped); + _logger.LogWarning( + "The published server URL for host '{PublishedServerHost}' does not end with the configured base URL '{BaseUrl}'. Jellyfin will append this base URL when generating Live TV client URLs. If your reverse proxy translates public paths, this may cause Live TV playback to fail. Update the Published Server URIs setting on the Networking page of the admin dashboard, the JELLYFIN_PublishedServerUrl environment variable / --published-server-url option, or the reverse proxy path mapping accordingly.", + publishedServerHost, + baseUrl); + } + } + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 5f7a0efe8a..d8cb9e1ac6 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -562,6 +562,95 @@ namespace Jellyfin.Networking.Tests 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>(); + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never()); + } + + /// + /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the + /// startup-configuration branch of InitializeOverrides and must funnel through the same + /// base URL check as the dashboard overrides. + /// + [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>(); + var startupConf = new Mock(); + 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(), + It.Is((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(), + It.IsAny>()), + Times.Never()); + } + + private static void VerifyBaseUrlWarning(Mock> logger, Times times) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + times); + } + /// /// is the piece of request-host /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address -- cgit v1.2.3