diff options
| author | Patrick Barron <barronpm@gmail.com> | 2023-11-30 11:30:19 -0500 |
|---|---|---|
| committer | Patrick Barron <barronpm@gmail.com> | 2023-11-30 11:57:07 -0500 |
| commit | 9597648ce3b96829429dc96235ca409914a8545b (patch) | |
| tree | e4b177d6a49147ac72d437f891817dc895b06fa2 /src | |
| parent | cc276838b4edbb67356b805952262c38e9c9cd19 (diff) | |
Move Jellyfin.Networking to src
Diffstat (limited to 'src')
| -rw-r--r-- | src/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs | 120 | ||||
| -rw-r--r-- | src/Jellyfin.Networking/Jellyfin.Networking.csproj | 16 | ||||
| -rw-r--r-- | src/Jellyfin.Networking/Manager/NetworkManager.cs | 1126 |
3 files changed, 1262 insertions, 0 deletions
diff --git a/src/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs b/src/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs new file mode 100644 index 000000000..d59e4e5e3 --- /dev/null +++ b/src/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs @@ -0,0 +1,120 @@ +/* +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System.IO; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Networking.HappyEyeballs +{ + /// <summary> + /// Defines the <see cref="HttpClientExtension"/> class. + /// + /// Implementation taken from https://github.com/ppy/osu-framework/pull/4191 . + /// </summary> + public static class HttpClientExtension + { + /// <summary> + /// Gets or sets a value indicating whether the client should use IPv6. + /// </summary> + public static bool UseIPv6 { get; set; } = true; + + /// <summary> + /// Implements the httpclient callback method. + /// </summary> + /// <param name="context">The <see cref="SocketsHttpConnectionContext"/> instance.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/> instance.</param> + /// <returns>The http steam.</returns> + public static async ValueTask<Stream> OnConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + if (!UseIPv6) + { + return await AttemptConnection(AddressFamily.InterNetwork, context, cancellationToken).ConfigureAwait(false); + } + + using var cancelIPv6 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tryConnectAsyncIPv6 = AttemptConnection(AddressFamily.InterNetworkV6, context, cancelIPv6.Token); + + // GetAwaiter().GetResult() is used instead of .Result as this results in improved exception handling. + // The tasks have already been completed. + // See https://github.com/dotnet/corefx/pull/29792/files#r189415885 for more details. + if (await Task.WhenAny(tryConnectAsyncIPv6, Task.Delay(200, cancelIPv6.Token)).ConfigureAwait(false) == tryConnectAsyncIPv6 && tryConnectAsyncIPv6.IsCompletedSuccessfully) + { + await cancelIPv6.CancelAsync().ConfigureAwait(false); + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + + using var cancelIPv4 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tryConnectAsyncIPv4 = AttemptConnection(AddressFamily.InterNetwork, context, cancelIPv4.Token); + + if (await Task.WhenAny(tryConnectAsyncIPv6, tryConnectAsyncIPv4).ConfigureAwait(false) == tryConnectAsyncIPv6) + { + if (tryConnectAsyncIPv6.IsCompletedSuccessfully) + { + await cancelIPv4.CancelAsync().ConfigureAwait(false); + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + + return tryConnectAsyncIPv4.GetAwaiter().GetResult(); + } + else + { + if (tryConnectAsyncIPv4.IsCompletedSuccessfully) + { + await cancelIPv6.CancelAsync().ConfigureAwait(false); + return tryConnectAsyncIPv4.GetAwaiter().GetResult(); + } + + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + } + + private static async Task<Stream> AttemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // The following socket constructor will create a dual-mode socket on systems where IPV6 is available. + var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp) + { + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + NoDelay = true + }; + + try + { + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + // The stream should take the ownership of the underlying socket, + // closing it when it's disposed. + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + } +} diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj new file mode 100644 index 000000000..70130b231 --- /dev/null +++ b/src/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\..\SharedVersion.cs" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> + <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> + </ItemGroup> +</Project> diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs new file mode 100644 index 000000000..749e0abbb --- /dev/null +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -0,0 +1,1126 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; +using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; + +namespace Jellyfin.Networking.Manager +{ + /// <summary> + /// Class to take care of network interface management. + /// </summary> + public class NetworkManager : INetworkManager, IDisposable + { + /// <summary> + /// Threading lock for network properties. + /// </summary> + private readonly object _initLock; + + private readonly ILogger<NetworkManager> _logger; + + private readonly IConfigurationManager _configurationManager; + + private readonly IConfiguration _startupConfig; + + private readonly object _networkEventLock; + + /// <summary> + /// Holds the published server URLs and the IPs to use them on. + /// </summary> + private IReadOnlyList<PublishedServerUriOverride> _publishedServerUrls; + + private IReadOnlyList<IPNetwork> _remoteAddressFilter; + + /// <summary> + /// Used to stop "event-racing conditions". + /// </summary> + private bool _eventfire; + + /// <summary> + /// List of all interface MAC addresses. + /// </summary> + private IReadOnlyList<PhysicalAddress> _macAddresses; + + /// <summary> + /// Dictionary containing interface addresses and their subnets. + /// </summary> + private IReadOnlyList<IPData> _interfaces; + + /// <summary> + /// Unfiltered user defined LAN subnets (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>) + /// or internal interface network subnets if undefined by user. + /// </summary> + private IReadOnlyList<IPNetwork> _lanSubnets; + + /// <summary> + /// User defined list of subnets to excluded from the LAN. + /// </summary> + private IReadOnlyList<IPNetwork> _excludedSubnets; + + /// <summary> + /// True if this object is disposed. + /// </summary> + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="NetworkManager"/> class. + /// </summary> + /// <param name="configurationManager">The <see cref="IConfigurationManager"/> instance.</param> + /// <param name="startupConfig">The <see cref="IConfiguration"/> instance holding startup parameters.</param> + /// <param name="logger">Logger to use for messages.</param> +#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this. + public NetworkManager(IConfigurationManager configurationManager, IConfiguration startupConfig, ILogger<NetworkManager> logger) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(configurationManager); + + _logger = logger; + _configurationManager = configurationManager; + _startupConfig = startupConfig; + _initLock = new(); + _interfaces = new List<IPData>(); + _macAddresses = new List<PhysicalAddress>(); + _publishedServerUrls = new List<PublishedServerUriOverride>(); + _networkEventLock = new object(); + _remoteAddressFilter = new List<IPNetwork>(); + + UpdateSettings(_configurationManager.GetNetworkConfiguration()); + + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + + _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated; + } +#pragma warning restore CS8618 // Non-nullable field is uninitialized. + + /// <summary> + /// Event triggered on network changes. + /// </summary> + public event EventHandler? NetworkChanged; + + /// <summary> + /// Gets or sets a value indicating whether testing is taking place. + /// </summary> + public static string MockNetworkSettings { get; set; } = string.Empty; + + /// <summary> + /// Gets a value indicating whether IP4 is enabled. + /// </summary> + public bool IsIPv4Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv4; + + /// <summary> + /// Gets a value indicating whether IP6 is enabled. + /// </summary> + public bool IsIPv6Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv6; + + /// <summary> + /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal. + /// </summary> + public bool TrustAllIPv6Interfaces { get; private set; } + + /// <summary> + /// Gets the Published server override list. + /// </summary> + public IReadOnlyList<PublishedServerUriOverride> PublishedServerUrls => _publishedServerUrls; + + /// <inheritdoc/> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param> + private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) + { + _logger.LogDebug("Network availability changed."); + HandleNetworkChange(); + } + + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">An <see cref="EventArgs"/>.</param> + private void OnNetworkAddressChanged(object? sender, EventArgs e) + { + _logger.LogDebug("Network address change detected."); + HandleNetworkChange(); + } + + /// <summary> + /// Triggers our event, and re-loads interface information. + /// </summary> + private void HandleNetworkChange() + { + lock (_networkEventLock) + { + if (!_eventfire) + { + // As network events tend to fire one after the other only fire once every second. + _eventfire = true; + OnNetworkChange(); + } + } + } + + /// <summary> + /// Waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession. + /// </summary> + private void OnNetworkChange() + { + try + { + Thread.Sleep(2000); + var networkConfig = _configurationManager.GetNetworkConfiguration(); + if (IsIPv6Enabled && !Socket.OSSupportsIPv6) + { + UpdateSettings(networkConfig); + } + else + { + InitializeInterfaces(); + InitializeLan(networkConfig); + EnforceBindSettings(networkConfig); + } + + PrintNetworkInformation(networkConfig); + NetworkChanged?.Invoke(this, EventArgs.Empty); + } + finally + { + _eventfire = false; + } + } + + /// <summary> + /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. + /// Generate a list of all active mac addresses that aren't loopback addresses. + /// </summary> + private void InitializeInterfaces() + { + lock (_initLock) + { + _logger.LogDebug("Refreshing interfaces."); + + var interfaces = new List<IPData>(); + var macAddresses = new List<PhysicalAddress>(); + + try + { + var nics = NetworkInterface.GetAllNetworkInterfaces() + .Where(i => i.OperationalStatus == OperationalStatus.Up); + + foreach (NetworkInterface adapter in nics) + { + try + { + var ipProperties = adapter.GetIPProperties(); + var mac = adapter.GetPhysicalAddress(); + + // Populate MAC list + if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && PhysicalAddress.None.Equals(mac)) + { + macAddresses.Add(mac); + } + + // Populate interface list + foreach (var info in ipProperties.UnicastAddresses) + { + if (IsIPv4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv4Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; + + interfaces.Add(interfaceObject); + } + else if (IsIPv6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv6Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; + + interfaces.Add(interfaceObject); + } + } + } + catch (Exception ex) + { + // Ignore error, and attempt to continue. + _logger.LogError(ex, "Error encountered parsing interfaces."); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error obtaining interfaces."); + } + + // If no interfaces are found, fallback to loopback interfaces. + if (interfaces.Count == 0) + { + _logger.LogWarning("No interface information available. Using loopback interface(s)."); + + if (IsIPv4Enabled) + { + interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); + } + + if (IsIPv6Enabled) + { + interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); + } + } + + _logger.LogDebug("Discovered {NumberOfInterfaces} interfaces.", interfaces.Count); + _logger.LogDebug("Interfaces addresses: {Addresses}", interfaces.OrderByDescending(s => s.AddressFamily == AddressFamily.InterNetwork).Select(s => s.Address.ToString())); + + _macAddresses = macAddresses; + _interfaces = interfaces; + } + } + + /// <summary> + /// Initializes internal LAN cache. + /// </summary> + private void InitializeLan(NetworkConfiguration config) + { + lock (_initLock) + { + _logger.LogDebug("Refreshing LAN information."); + + // Get configuration options + var subnets = config.LocalNetworkSubnets; + + // If no LAN addresses are specified, all private subnets and Loopback are deemed to be the LAN + if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false) || lanSubnets.Count == 0) + { + _logger.LogDebug("Using LAN interface addresses as user provided no LAN details."); + + var fallbackLanSubnets = new List<IPNetwork>(); + if (IsIPv6Enabled) + { + fallbackLanSubnets.Add(NetworkConstants.IPv6RFC4291Loopback); // RFC 4291 (Loopback) + fallbackLanSubnets.Add(NetworkConstants.IPv6RFC4291SiteLocal); // RFC 4291 (Site local) + fallbackLanSubnets.Add(NetworkConstants.IPv6RFC4193UniqueLocal); // RFC 4193 (Unique local) + } + + if (IsIPv4Enabled) + { + fallbackLanSubnets.Add(NetworkConstants.IPv4RFC5735Loopback); // RFC 5735 (Loopback) + fallbackLanSubnets.Add(NetworkConstants.IPv4RFC1918PrivateClassA); // RFC 1918 (private Class A) + fallbackLanSubnets.Add(NetworkConstants.IPv4RFC1918PrivateClassB); // RFC 1918 (private Class B) + fallbackLanSubnets.Add(NetworkConstants.IPv4RFC1918PrivateClassC); // RFC 1918 (private Class C) + } + + _lanSubnets = fallbackLanSubnets; + } + else + { + _lanSubnets = lanSubnets; + } + + _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true) + ? excludedSubnets + : new List<IPNetwork>(); + } + } + + /// <summary> + /// Enforce bind addresses and exclusions on available interfaces. + /// </summary> + private void EnforceBindSettings(NetworkConfiguration config) + { + lock (_initLock) + { + // Respect explicit bind addresses + var interfaces = _interfaces.ToList(); + var localNetworkAddresses = config.LocalNetworkAddresses; + if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) + { + var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network) + ? network.Prefix + : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Address) + .FirstOrDefault() ?? IPAddress.None)) + .Where(x => x != IPAddress.None) + .ToHashSet(); + interfaces = interfaces.Where(x => bindAddresses.Contains(x.Address)).ToList(); + + if (bindAddresses.Contains(IPAddress.Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.Loopback))) + { + interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); + } + + if (bindAddresses.Contains(IPAddress.IPv6Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.IPv6Loopback))) + { + interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); + } + } + + // Remove all interfaces matching any virtual machine interface prefix + if (config.IgnoreVirtualInterfaces) + { + // Remove potentially existing * and split config string into prefixes + var virtualInterfacePrefixes = config.VirtualInterfaceNames + .Select(i => i.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase)); + + // Check all interfaces for matches against the prefixes and remove them + if (_interfaces.Count > 0) + { + foreach (var virtualInterfacePrefix in virtualInterfacePrefixes) + { + interfaces.RemoveAll(x => x.Name.StartsWith(virtualInterfacePrefix, StringComparison.OrdinalIgnoreCase)); + } + } + } + + // Remove all IPv4 interfaces if IPv4 is disabled + if (!IsIPv4Enabled) + { + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetwork); + } + + // Remove all IPv6 interfaces if IPv6 is disabled + if (!IsIPv6Enabled) + { + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6); + } + + _interfaces = interfaces; + } + } + + /// <summary> + /// Initializes the remote address values. + /// </summary> + private void InitializeRemote(NetworkConfiguration config) + { + lock (_initLock) + { + // Parse config values into filter collection + var remoteIPFilter = config.RemoteIPFilter; + if (remoteIPFilter.Length != 0 && !string.IsNullOrWhiteSpace(remoteIPFilter[0])) + { + // Parse all IPs with netmask to a subnet + var remoteAddressFilter = new List<IPNetwork>(); + var remoteFilteredSubnets = remoteIPFilter.Where(x => x.Contains('/', StringComparison.OrdinalIgnoreCase)).ToArray(); + if (NetworkUtils.TryParseToSubnets(remoteFilteredSubnets, out var remoteAddressFilterResult, false)) + { + remoteAddressFilter = remoteAddressFilterResult.ToList(); + } + + // Parse everything else as an IP and construct subnet with a single IP + var remoteFilteredIPs = remoteIPFilter.Where(x => !x.Contains('/', StringComparison.OrdinalIgnoreCase)); + foreach (var ip in remoteFilteredIPs) + { + if (IPAddress.TryParse(ip, out var ipp)) + { + remoteAddressFilter.Add(new IPNetwork(ipp, ipp.AddressFamily == AddressFamily.InterNetwork ? NetworkConstants.MinimumIPv4PrefixSize : NetworkConstants.MinimumIPv6PrefixSize)); + } + } + + _remoteAddressFilter = remoteAddressFilter; + } + } + } + + /// <summary> + /// Parses the user defined overrides into the dictionary object. + /// Overrides are the equivalent of localised publishedServerUrl, enabling + /// different addresses to be advertised over different subnets. + /// format is subnet=ipaddress|host|uri + /// when subnet = 0.0.0.0, any external address matches. + /// </summary> + private void InitializeOverrides(NetworkConfiguration config) + { + lock (_initLock) + { + var publishedServerUrls = new List<PublishedServerUriOverride>(); + + // Prefer startup configuration. + var startupOverrideKey = _startupConfig[AddressOverrideKey]; + if (!string.IsNullOrEmpty(startupOverrideKey)) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, NetworkConstants.IPv4Any), + startupOverrideKey, + true, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any), + startupOverrideKey, + true, + true)); + _publishedServerUrls = publishedServerUrls; + return; + } + + var overrides = config.PublishedServerUriBySubnet; + foreach (var entry in overrides) + { + var parts = entry.Split('='); + if (parts.Length != 2) + { + _logger.LogError("Unable to parse bind override: {Entry}", entry); + return; + } + + var replacement = parts[1].Trim(); + var identifier = parts[0]; + if (string.Equals(identifier, "all", StringComparison.OrdinalIgnoreCase)) + { + // Drop any other overrides in case an "all" override exists + publishedServerUrls.Clear(); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, NetworkConstants.IPv4Any), + replacement, + true, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any), + replacement, + true, + true)); + break; + } + else if (string.Equals(identifier, "external", StringComparison.OrdinalIgnoreCase)) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, NetworkConstants.IPv4Any), + replacement, + false, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any), + replacement, + false, + true)); + } + else if (string.Equals(identifier, "internal", StringComparison.OrdinalIgnoreCase)) + { + foreach (var lan in _lanSubnets) + { + var lanPrefix = lan.Prefix; + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)), + replacement, + true, + false)); + } + } + else if (NetworkUtils.TryParseToSubnet(identifier, out var result) && result is not null) + { + var data = new IPData(result.Prefix, result); + publishedServerUrls.Add( + new PublishedServerUriOverride( + data, + replacement, + true, + true)); + } + else if (TryParseInterface(identifier, out var ifaces)) + { + foreach (var iface in ifaces) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + iface, + replacement, + true, + true)); + } + } + else + { + _logger.LogError("Unable to parse bind override: {Entry}", entry); + } + } + + _publishedServerUrls = publishedServerUrls; + } + } + + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) + { + if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) + { + UpdateSettings((NetworkConfiguration)evt.NewConfiguration); + } + } + + /// <summary> + /// Reloads all settings and re-Initializes the instance. + /// </summary> + /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param> + public void UpdateSettings(object configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var config = (NetworkConfiguration)configuration; + HappyEyeballs.HttpClientExtension.UseIPv6 = config.EnableIPv6; + + InitializeLan(config); + InitializeRemote(config); + + if (string.IsNullOrEmpty(MockNetworkSettings)) + { + InitializeInterfaces(); + } + else // Used in testing only. + { + // Format is <IPAddress>,<Index>,<Name>: <next interface>. Set index to -ve to simulate a gateway. + var interfaceList = MockNetworkSettings.Split('|'); + var interfaces = new List<IPData>(); + foreach (var details in interfaceList) + { + var parts = details.Split(','); + if (NetworkUtils.TryParseToSubnet(parts[0], out var subnet)) + { + var address = subnet.Prefix; + var index = int.Parse(parts[1], CultureInfo.InvariantCulture); + if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6) + { + var data = new IPData(address, subnet, parts[2]) + { + Index = index + }; + interfaces.Add(data); + } + } + else + { + _logger.LogWarning("Could not parse mock interface settings: {Part}", details); + } + } + + _interfaces = interfaces; + } + + EnforceBindSettings(config); + InitializeOverrides(config); + + PrintNetworkInformation(config, false); + } + + /// <summary> + /// Protected implementation of Dispose pattern. + /// </summary> + /// <param name="disposing"><c>True</c> to dispose the managed state.</param> + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated; + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; + } + + _disposed = true; + } + } + + /// <inheritdoc/> + public bool TryParseInterface(string intf, [NotNullWhen(true)] out IReadOnlyList<IPData>? result) + { + if (string.IsNullOrEmpty(intf) + || _interfaces is null + || _interfaces.Count == 0) + { + result = null; + return false; + } + + // Match all interfaces starting with names starting with token + result = _interfaces + .Where(i => i.Name.Equals(intf, StringComparison.OrdinalIgnoreCase) + && ((IsIPv4Enabled && i.Address.AddressFamily == AddressFamily.InterNetwork) + || (IsIPv6Enabled && i.Address.AddressFamily == AddressFamily.InterNetworkV6))) + .OrderBy(x => x.Index) + .ToArray(); + return result.Count > 0; + } + + /// <inheritdoc/> + public bool HasRemoteAccess(IPAddress remoteIP) + { + var config = _configurationManager.GetNetworkConfiguration(); + if (config.EnableRemoteAccess) + { + // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. + // If left blank, all remote addresses will be allowed. + if (_remoteAddressFilter.Any() && !_lanSubnets.Any(x => x.Contains(remoteIP))) + { + // remoteAddressFilter is a whitelist or blacklist. + var matches = _remoteAddressFilter.Count(remoteNetwork => remoteNetwork.Contains(remoteIP)); + if ((!config.IsRemoteIPFilterBlacklist && matches > 0) + || (config.IsRemoteIPFilterBlacklist && matches == 0)) + { + return true; + } + + return false; + } + } + else if (!_lanSubnets.Any(x => x.Contains(remoteIP))) + { + // Remote not enabled. So everyone should be LAN. + return false; + } + + return true; + } + + /// <inheritdoc/> + public IReadOnlyList<PhysicalAddress> GetMacAddresses() + { + // Populated in construction - so always has values. + return _macAddresses; + } + + /// <inheritdoc/> + public IReadOnlyList<IPData> GetLoopbacks() + { + if (!IsIPv4Enabled && !IsIPv6Enabled) + { + return Array.Empty<IPData>(); + } + + var loopbackNetworks = new List<IPData>(); + if (IsIPv4Enabled) + { + loopbackNetworks.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); + } + + if (IsIPv6Enabled) + { + loopbackNetworks.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); + } + + return loopbackNetworks; + } + + /// <inheritdoc/> + public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false) + { + if (_interfaces.Count > 0 || individualInterfaces) + { + return _interfaces; + } + + // No bind address and no exclusions, so listen on all interfaces. + var result = new List<IPData>(); + if (IsIPv4Enabled && IsIPv6Enabled) + { + // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any by default + result.Add(new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any)); + } + else if (IsIPv4Enabled) + { + result.Add(new IPData(IPAddress.Any, NetworkConstants.IPv4Any)); + } + else if (IsIPv6Enabled) + { + // Cannot use IPv6Any as Kestrel will bind to IPv4 addresses too. + foreach (var iface in _interfaces) + { + if (iface.AddressFamily == AddressFamily.InterNetworkV6) + { + result.Add(iface); + } + } + } + + return result; + } + + /// <inheritdoc/> + public string GetBindAddress(string source, out int? port) + { + if (!NetworkUtils.TryParseHost(source, out var addresses, IsIPv4Enabled, IsIPv6Enabled)) + { + addresses = Array.Empty<IPAddress>(); + } + + var result = GetBindAddress(addresses.FirstOrDefault(), out port); + return result; + } + + /// <inheritdoc/> + public string GetBindAddress(HttpRequest source, out int? port) + { + var result = GetBindAddress(source.Host.Host, out port); + port ??= source.Host.Port; + + return result; + } + + /// <inheritdoc/> + public string GetBindAddress(IPAddress? source, out int? port, bool skipOverrides = false) + { + port = null; + + string result; + + if (source is not null) + { + if (IsIPv4Enabled && !IsIPv6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6) + { + _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); + } + + if (!IsIPv4Enabled && IsIPv6Enabled && source.AddressFamily == AddressFamily.InterNetwork) + { + _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); + } + + bool isExternal = !_lanSubnets.Any(network => network.Contains(source)); + _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); + + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) + { + return result; + } + + // No preference given, so move on to bind addresses. + if (MatchesBindInterface(source, isExternal, out result)) + { + return result; + } + + if (isExternal && MatchesExternalInterface(source, out result)) + { + return result; + } + } + + // Get the first LAN interface address that's not excluded and not a loopback address. + // Get all available interfaces, prefer local interfaces + var availableInterfaces = _interfaces.Where(x => !IPAddress.IsLoopback(x.Address)) + .OrderByDescending(x => IsInLocalNetwork(x.Address)) + .ThenBy(x => x.Index) + .ToList(); + + if (availableInterfaces.Count == 0) + { + // There isn't any others, so we'll use the loopback. + result = IsIPv4Enabled && !IsIPv6Enabled ? "127.0.0.1" : "::1"; + _logger.LogWarning("{Source}: Only loopback {Result} returned, using that as bind address.", source, result); + return result; + } + + // If no source address is given, use the preferred (first) interface + if (source is null) + { + result = NetworkUtils.FormatIPString(availableInterfaces.First().Address); + _logger.LogDebug("{Source}: Using first internal interface as bind address: {Result}", source, result); + return result; + } + + // Does the request originate in one of the interface subnets? + // (For systems with multiple internal network cards, and multiple subnets) + foreach (var intf in availableInterfaces) + { + if (intf.Subnet.Contains(source)) + { + result = NetworkUtils.FormatIPString(intf.Address); + _logger.LogDebug("{Source}: Found interface with matching subnet, using it as bind address: {Result}", source, result); + return result; + } + } + + // Fallback to first available interface + result = NetworkUtils.FormatIPString(availableInterfaces[0].Address); + _logger.LogDebug("{Source}: No matching interfaces found, using preferred interface as bind address: {Result}", source, result); + return result; + } + + /// <inheritdoc/> + public IReadOnlyList<IPData> GetInternalBindAddresses() + { + // Select all local bind addresses + return _interfaces.Where(x => IsInLocalNetwork(x.Address)) + .OrderBy(x => x.Index) + .ToList(); + } + + /// <inheritdoc/> + public bool IsInLocalNetwork(string address) + { + if (NetworkUtils.TryParseToSubnet(address, out var subnet)) + { + return IPAddress.IsLoopback(subnet.Prefix) || (_lanSubnets.Any(x => x.Contains(subnet.Prefix)) && !_excludedSubnets.Any(x => x.Contains(subnet.Prefix))); + } + + if (NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled)) + { + foreach (var ept in addresses) + { + if (IPAddress.IsLoopback(ept) || (_lanSubnets.Any(x => x.Contains(ept)) && !_excludedSubnets.Any(x => x.Contains(ept)))) + { + return true; + } + } + } + + return false; + } + + /// <inheritdoc/> + public bool IsInLocalNetwork(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + + // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + if ((TrustAllIPv6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + || address.Equals(IPAddress.Loopback) + || address.Equals(IPAddress.IPv6Loopback)) + { + return true; + } + + // As private addresses can be redefined by Configuration.LocalNetworkAddresses + return CheckIfLanAndNotExcluded(address); + } + + private bool CheckIfLanAndNotExcluded(IPAddress address) + { + foreach (var lanSubnet in _lanSubnets) + { + if (lanSubnet.Contains(address)) + { + foreach (var excludedSubnet in _excludedSubnets) + { + if (excludedSubnet.Contains(address)) + { + return false; + } + } + + return true; + } + } + + return false; + } + + /// <summary> + /// Attempts to match the source against the published server URL overrides. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="isInExternalSubnet">True if the source is in an external subnet.</param> + /// <param name="bindPreference">The published server URL that matches the source address.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) + { + bindPreference = string.Empty; + int? port = null; + + // Only consider subnets including the source IP, prefering specific overrides + List<PublishedServerUriOverride> validPublishedServerUrls; + if (!isInExternalSubnet) + { + // Only use matching internal subnets + // Prefer more specific (bigger subnet prefix) overrides + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && x.Data.Subnet.Contains(source)) + .OrderByDescending(x => x.Data.Subnet.PrefixLength) + .ToList(); + } + else + { + // Only use matching external subnets + // Prefer more specific (bigger subnet prefix) overrides + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && x.Data.Subnet.Contains(source)) + .OrderByDescending(x => x.Data.Subnet.PrefixLength) + .ToList(); + } + + foreach (var data in validPublishedServerUrls) + { + // Get interface matching override subnet + var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address)); + + if (intf?.Address is not null) + { + // If matching interface is found, use override + bindPreference = data.OverrideUri; + break; + } + } + + if (string.IsNullOrEmpty(bindPreference)) + { + _logger.LogDebug("{Source}: No matching bind address override found", source); + return false; + } + + // Handle override specifying port + var parts = bindPreference.Split(':'); + if (parts.Length > 1) + { + 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}", source, bindPreference); + return true; + } + + /// <summary> + /// Attempts to match the source against the user defined bind interfaces. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param> + /// <param name="result">The result, if a match is found.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesBindInterface(IPAddress source, bool isInExternalSubnet, out string result) + { + result = string.Empty; + + int count = _interfaces.Count; + if (count == 1 && (_interfaces[0].Equals(IPAddress.Any) || _interfaces[0].Equals(IPAddress.IPv6Any))) + { + // Ignore IPAny addresses. + count = 0; + } + + if (count == 0) + { + return false; + } + + IPAddress? bindAddress = null; + if (isInExternalSubnet) + { + var externalInterfaces = _interfaces.Where(x => !IsInLocalNetwork(x.Address)) + .OrderBy(x => x.Index) + .ToList(); + if (externalInterfaces.Count > 0) + { + // Check to see if any of the external bind interfaces are in the same subnet as the source. + // If none exists, this will select the first external interface if there is one. + bindAddress = externalInterfaces + .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenBy(x => x.Index) + .Select(x => x.Address) + .First(); + + result = NetworkUtils.FormatIPString(bindAddress); + _logger.LogDebug("{Source}: External request received, matching external bind address found: {Result}", source, result); + return true; + } + + _logger.LogWarning("{Source}: External request received, no matching external bind address found, trying internal addresses.", source); + } + else + { + // Check to see if any of the internal bind interfaces are in the same subnet as the source. + // If none exists, this will select the first internal interface if there is one. + bindAddress = _interfaces.Where(x => IsInLocalNetwork(x.Address)) + .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenBy(x => x.Index) + .Select(x => x.Address) + .FirstOrDefault(); + + if (bindAddress is not null) + { + result = NetworkUtils.FormatIPString(bindAddress); + _logger.LogDebug("{Source}: Internal request received, matching internal bind address found: {Result}", source, result); + return true; + } + } + + return false; + } + + /// <summary> + /// Attempts to match the source against external interfaces. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="result">The result, if a match is found.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesExternalInterface(IPAddress source, out string result) + { + // Get the first external interface address that isn't a loopback. + var extResult = _interfaces.Where(p => !IsInLocalNetwork(p.Address)).OrderBy(x => x.Index).ToArray(); + + // No external interface found + if (extResult.Length == 0) + { + result = string.Empty; + _logger.LogWarning("{Source}: External request received, but no external interface found. Need to route through internal network.", source); + return false; + } + + // Does the request originate in one of the interface subnets? + // (For systems with multiple network cards and/or multiple subnets) + foreach (var intf in extResult) + { + if (intf.Subnet.Contains(source)) + { + result = NetworkUtils.FormatIPString(intf.Address); + _logger.LogDebug("{Source}: Found external interface with matching subnet, using it as bind address: {Result}", source, result); + return true; + } + } + + // Fallback to first external interface. + result = NetworkUtils.FormatIPString(extResult[0].Address); + _logger.LogDebug("{Source}: Using first external interface as bind address: {Result}", source, result); + return true; + } + + private void PrintNetworkInformation(NetworkConfiguration config, bool debug = true) + { + var logLevel = debug ? LogLevel.Debug : LogLevel.Information; + if (_logger.IsEnabled(logLevel)) + { + _logger.Log(logLevel, "Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Using bind addresses: {0}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); + _logger.Log(logLevel, "Remote IP filter is {0}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); + _logger.Log(logLevel, "Filter list: {0}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); + } + } + } +} |
