diff --git a/eng/test-configuration.json b/eng/test-configuration.json index 1da9b61de6c6..3f5af7a1b766 100644 --- a/eng/test-configuration.json +++ b/eng/test-configuration.json @@ -21,6 +21,7 @@ {"testName": {"contains": "InjectedStartup_DefaultApplicationNameIsEntryAssembly"}}, {"testName": {"contains": "HEADERS_Received_SecondRequest_ConnectProtocolReset"}}, {"testName": {"contains": "ClientUsingOldCallWithNewProtocol"}}, + {"testName": {"contains": "CertificateChangedOnDisk"}}, {"testAssembly": {"contains": "IIS"}}, {"testAssembly": {"contains": "Template"}}, {"failureMessage": {"contains":"(Site is started but no worker process found)"}}, diff --git a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs new file mode 100644 index 000000000000..7cb7234ac0a8 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs @@ -0,0 +1,328 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal; + +internal sealed partial class CertificatePathWatcher : IDisposable +{ + private readonly Func _fileProviderFactory; + private readonly string _contentRootDir; + private readonly ILogger _logger; + + private readonly object _metadataLock = new(); + + /// Acquire before accessing. + private readonly Dictionary _metadataForDirectory = new(); + /// Acquire before accessing. + private readonly Dictionary _metadataForFile = new(); + + private ConfigurationReloadToken _reloadToken = new(); + private bool _disposed; + + public CertificatePathWatcher(IHostEnvironment hostEnvironment, ILogger logger) + : this( + hostEnvironment.ContentRootPath, + logger, + dir => Directory.Exists(dir) ? new PhysicalFileProvider(dir, ExclusionFilters.None) : null) + { + } + + /// + /// For testing. + /// + internal CertificatePathWatcher(string contentRootPath, ILogger logger, Func fileProviderFactory) + { + _contentRootDir = contentRootPath; + _logger = logger; + _fileProviderFactory = fileProviderFactory; + } + + /// + /// Returns a token that will fire when any watched is changed on disk. + /// The affected will have + /// set to true. + /// + public IChangeToken GetChangeToken() + { + return _reloadToken; + } + + /// + /// Update the set of s being watched for file changes. + /// If a given appears in both lists, it is first removed and then added. + /// + /// + /// Does not consider targets when watching files that are symlinks. + /// + public void UpdateWatches(List certificateConfigsToRemove, List certificateConfigsToAdd) + { + var addSet = new HashSet(certificateConfigsToAdd, ReferenceEqualityComparer.Instance); + var removeSet = new HashSet(certificateConfigsToRemove, ReferenceEqualityComparer.Instance); + + // Don't remove anything we're going to re-add anyway. + // Don't remove such items from addSet to guard against the (hypothetical) possibility + // that a caller might remove a config that isn't already present. + removeSet.ExceptWith(certificateConfigsToAdd); + + if (addSet.Count == 0 && removeSet.Count == 0) + { + return; + } + + lock (_metadataLock) + { + // Adds before removes to increase the chances of watcher reuse. + // Since removeSet doesn't contain any of these configs, this won't change the semantics. + foreach (var certificateConfig in addSet) + { + AddWatchUnsynchronized(certificateConfig); + } + + foreach (var certificateConfig in removeSet) + { + RemoveWatchUnsynchronized(certificateConfig); + } + } + } + + /// + /// Start watching a certificate's file path for changes. + /// must have set to true. + /// + /// + /// Internal for testing. + /// + internal void AddWatchUnsynchronized(CertificateConfig certificateConfig) + { + Debug.Assert(certificateConfig.IsFileCert, "AddWatch called on non-file cert"); + + var path = Path.Combine(_contentRootDir, certificateConfig.Path); + var dir = Path.GetDirectoryName(path)!; + + if (!_metadataForDirectory.TryGetValue(dir, out var dirMetadata)) + { + // If we wanted to detected deletions of this whole directory (which we don't since we ignore deletions), + // we'd probably need to watch the whole directory hierarchy + + var fileProvider = _fileProviderFactory(dir); + if (fileProvider is null) + { + _logger.DirectoryDoesNotExist(dir, path); + return; + } + + dirMetadata = new DirectoryWatchMetadata(fileProvider); + _metadataForDirectory.Add(dir, dirMetadata); + + _logger.CreatedDirectoryWatcher(dir); + } + + if (!_metadataForFile.TryGetValue(path, out var fileMetadata)) + { + // PhysicalFileProvider appears to be able to tolerate non-existent files, as long as the directory exists + + var disposable = ChangeToken.OnChange( + () => dirMetadata.FileProvider.Watch(Path.GetFileName(path)), + static tuple => tuple.Item1.OnChange(tuple.Item2), + ValueTuple.Create(this, path)); + + fileMetadata = new FileWatchMetadata(disposable); + _metadataForFile.Add(path, fileMetadata); + dirMetadata.FileWatchCount++; + + // We actually don't care if the file doesn't exist - we'll watch in case it is created + fileMetadata.LastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider); + + _logger.CreatedFileWatcher(path); + } + + if (!fileMetadata.Configs.Add(certificateConfig)) + { + _logger.ReusedObserver(path); + return; + } + + _logger.AddedObserver(path); + + _logger.ObserverCount(path, fileMetadata.Configs.Count); + _logger.FileCount(dir, dirMetadata.FileWatchCount); + } + + private DateTimeOffset GetLastModifiedTimeOrMinimum(string path, IFileProvider fileProvider) + { + try + { + return fileProvider.GetFileInfo(Path.GetFileName(path)).LastModified; + } + catch (Exception e) + { + _logger.LastModifiedTimeError(path, e); + } + + return DateTimeOffset.MinValue; + } + + private void OnChange(string path) + { + // Block until any in-progress updates are complete + lock (_metadataLock) + { + if (!_metadataForFile.TryGetValue(path, out var fileMetadata)) + { + _logger.UntrackedFileEvent(path); + return; + } + + // Existence implied by the fact that we're tracking the file + var dirMetadata = _metadataForDirectory[Path.GetDirectoryName(path)!]; + + // We ignore file changes that don't advance the last modified time. + // For example, if we lose access to the network share the file is + // stored on, we don't notify our listeners because no one wants + // their endpoint/server to shutdown when that happens. + // We also anticipate that a cert file might be renamed to cert.bak + // before a new cert is introduced with the old name. + // This also helps us in scenarios where the underlying file system + // reports more than one change for a single logical operation. + var lastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider); + if (lastModifiedTime > fileMetadata.LastModifiedTime) + { + fileMetadata.LastModifiedTime = lastModifiedTime; + } + else + { + if (lastModifiedTime == DateTimeOffset.MinValue) + { + _logger.EventWithoutLastModifiedTime(path); + } + else if (lastModifiedTime == fileMetadata.LastModifiedTime) + { + _logger.RedundantEvent(path); + } + else + { + _logger.OutOfOrderEvent(path); + } + return; + } + + var configs = fileMetadata.Configs; + foreach (var config in configs) + { + config.FileHasChanged = true; + } + } + + // AddWatch and RemoveWatch don't affect the token, so this doesn't need to be under the semaphore. + // It does however need to be synchronized, since there could be multiple concurrent events. + var previousToken = Interlocked.Exchange(ref _reloadToken, new()); + previousToken.OnReload(); + } + + /// + /// Stop watching a certificate's file path for changes (previously started by . + /// must have set to true. + /// + /// + /// Internal for testing. + /// + internal void RemoveWatchUnsynchronized(CertificateConfig certificateConfig) + { + Debug.Assert(certificateConfig.IsFileCert, "RemoveWatch called on non-file cert"); + + var path = Path.Combine(_contentRootDir, certificateConfig.Path); + var dir = Path.GetDirectoryName(path)!; + + if (!_metadataForFile.TryGetValue(path, out var fileMetadata)) + { + _logger.UnknownFile(path); + return; + } + + var configs = fileMetadata.Configs; + + if (!configs.Remove(certificateConfig)) + { + _logger.UnknownObserver(path); + return; + } + + _logger.RemovedObserver(path); + + // If we found fileMetadata, there must be a containing/corresponding dirMetadata + var dirMetadata = _metadataForDirectory[dir]; + + if (configs.Count == 0) + { + fileMetadata.Dispose(); + _metadataForFile.Remove(path); + dirMetadata.FileWatchCount--; + + _logger.RemovedFileWatcher(path); + + if (dirMetadata.FileWatchCount == 0) + { + dirMetadata.Dispose(); + _metadataForDirectory.Remove(dir); + + _logger.RemovedDirectoryWatcher(dir); + } + } + + _logger.ObserverCount(path, configs.Count); + _logger.FileCount(dir, dirMetadata.FileWatchCount); + } + + /// Test hook + internal int TestGetDirectoryWatchCountUnsynchronized() => _metadataForDirectory.Count; + + /// Test hook + internal int TestGetFileWatchCountUnsynchronized(string dir) => _metadataForDirectory.TryGetValue(dir, out var metadata) ? metadata.FileWatchCount : 0; + + /// Test hook + internal int TestGetObserverCountUnsynchronized(string path) => _metadataForFile.TryGetValue(path, out var metadata) ? metadata.Configs.Count : 0; + + void IDisposable.Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + foreach (var dirMetadata in _metadataForDirectory.Values) + { + dirMetadata.Dispose(); + } + + foreach (var fileMetadata in _metadataForFile.Values) + { + fileMetadata.Dispose(); + } + } + + private sealed class DirectoryWatchMetadata(IFileProvider fileProvider) : IDisposable + { + public readonly IFileProvider FileProvider = fileProvider; + public int FileWatchCount; + + public void Dispose() => (FileProvider as IDisposable)?.Dispose(); + } + + private sealed class FileWatchMetadata(IDisposable disposable) : IDisposable + { + public readonly IDisposable Disposable = disposable; + public readonly HashSet Configs = new(ReferenceEqualityComparer.Instance); + public DateTimeOffset LastModifiedTime = DateTimeOffset.MinValue; + + public void Dispose() => Disposable.Dispose(); + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs new file mode 100644 index 000000000000..d41543a342a6 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal; + +internal static partial class CertificatePathWatcherLoggerExtensions +{ + [LoggerMessage(1, LogLevel.Warning, "Directory '{Directory}' does not exist so changes to the certificate '{Path}' will not be tracked.", EventName = "DirectoryDoesNotExist")] + public static partial void DirectoryDoesNotExist(this ILogger logger, string directory, string path); + + [LoggerMessage(2, LogLevel.Warning, "Attempted to remove watch from unwatched path '{Path}'.", EventName = "UnknownFile")] + public static partial void UnknownFile(this ILogger logger, string path); + + [LoggerMessage(3, LogLevel.Warning, "Attempted to remove unknown observer from path '{Path}'.", EventName = "UnknownObserver")] + public static partial void UnknownObserver(this ILogger logger, string path); + + [LoggerMessage(4, LogLevel.Debug, "Created directory watcher for '{Directory}'.", EventName = "CreatedDirectoryWatcher")] + public static partial void CreatedDirectoryWatcher(this ILogger logger, string directory); + + [LoggerMessage(5, LogLevel.Debug, "Created file watcher for '{Path}'.", EventName = "CreatedFileWatcher")] + public static partial void CreatedFileWatcher(this ILogger logger, string path); + + [LoggerMessage(6, LogLevel.Debug, "Removed directory watcher for '{Directory}'.", EventName = "RemovedDirectoryWatcher")] + public static partial void RemovedDirectoryWatcher(this ILogger logger, string directory); + + [LoggerMessage(7, LogLevel.Debug, "Removed file watcher for '{Path}'.", EventName = "RemovedFileWatcher")] + public static partial void RemovedFileWatcher(this ILogger logger, string path); + + [LoggerMessage(8, LogLevel.Debug, "Error retrieving last modified time for '{Path}'.", EventName = "LastModifiedTimeError")] + public static partial void LastModifiedTimeError(this ILogger logger, string path, Exception e); + + [LoggerMessage(9, LogLevel.Debug, "Ignored event for presently untracked file '{Path}'.", EventName = "UntrackedFileEvent")] + public static partial void UntrackedFileEvent(this ILogger logger, string path); + + [LoggerMessage(10, LogLevel.Debug, "Ignored out-of-order event for file '{Path}'.", EventName = "OutOfOrderEvent")] + public static partial void OutOfOrderEvent(this ILogger logger, string path); + + [LoggerMessage(11, LogLevel.Trace, "Reused existing observer on file watcher for '{Path}'.", EventName = "ReusedObserver")] + public static partial void ReusedObserver(this ILogger logger, string path); + + [LoggerMessage(12, LogLevel.Trace, "Added observer to file watcher for '{Path}'.", EventName = "AddedObserver")] + public static partial void AddedObserver(this ILogger logger, string path); + + [LoggerMessage(13, LogLevel.Trace, "Removed observer from file watcher for '{Path}'.", EventName = "RemovedObserver")] + public static partial void RemovedObserver(this ILogger logger, string path); + + [LoggerMessage(14, LogLevel.Trace, "File '{Path}' now has {Count} observers.", EventName = "ObserverCount")] + public static partial void ObserverCount(this ILogger logger, string path, int count); + + [LoggerMessage(15, LogLevel.Trace, "Directory '{Directory}' now has watchers on {Count} files.", EventName = "FileCount")] + public static partial void FileCount(this ILogger logger, string directory, int count); + + [LoggerMessage(16, LogLevel.Trace, "Ignored event since last modified time for '{Path}' was unavailable.", EventName = "EventWithoutLastModifiedTime")] + public static partial void EventWithoutLastModifiedTime(this ILogger logger, string path); + + [LoggerMessage(17, LogLevel.Trace, "Ignored redundant event for '{Path}'.", EventName = "RedundantEvent")] + public static partial void RedundantEvent(this ILogger logger, string path); + + [LoggerMessage(18, LogLevel.Trace, "Flagged {Count} observers of '{Path}' as changed.", EventName = "FlaggedObservers")] + public static partial void FlaggedObservers(this ILogger logger, string path, int count); +} diff --git a/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs b/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs index 152026d05edf..7b27228e3cae 100644 --- a/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs +++ b/src/Servers/Kestrel/Core/src/Internal/ConfigurationReader.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security.Authentication; using Microsoft.AspNetCore.Server.Kestrel.Https; @@ -388,37 +389,47 @@ internal CertificateConfig() public IConfigurationSection? ConfigSection { get; } // File + + [MemberNotNullWhen(true, nameof(Path))] public bool IsFileCert => !string.IsNullOrEmpty(Path); - public string? Path { get; set; } + public string? Path { get; init; } + + public string? KeyPath { get; init; } - public string? KeyPath { get; set; } + public string? Password { get; init; } - public string? Password { get; set; } + /// + /// Vacuously false if this isn't a file cert. + /// Used for change tracking - not actually part of configuring the certificate. + /// + public bool FileHasChanged { get; internal set; } // Cert store + [MemberNotNullWhen(true, nameof(Subject))] public bool IsStoreCert => !string.IsNullOrEmpty(Subject); - public string? Subject { get; set; } + public string? Subject { get; init; } - public string? Store { get; set; } + public string? Store { get; init; } - public string? Location { get; set; } + public string? Location { get; init; } - public bool? AllowInvalid { get; set; } + public bool? AllowInvalid { get; init; } public override bool Equals(object? obj) => obj is CertificateConfig other && Path == other.Path && KeyPath == other.KeyPath && Password == other.Password && + FileHasChanged == other.FileHasChanged && Subject == other.Subject && Store == other.Store && Location == other.Location && (AllowInvalid ?? false) == (other.AllowInvalid ?? false); - public override int GetHashCode() => HashCode.Combine(Path, KeyPath, Password, Subject, Store, Location, AllowInvalid ?? false); + public override int GetHashCode() => HashCode.Combine(Path, KeyPath, Password, FileHasChanged, Subject, Store, Location, AllowInvalid ?? false); public static bool operator ==(CertificateConfig? lhs, CertificateConfig? rhs) => lhs is null ? rhs is null : lhs.Equals(rhs); public static bool operator !=(CertificateConfig? lhs, CertificateConfig? rhs) => !(lhs == rhs); diff --git a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs index e749d52f2016..b8af73a906dd 100644 --- a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs +++ b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs @@ -300,7 +300,7 @@ private async Task BindAsync(CancellationToken cancellationToken) if (Options.ConfigurationLoader?.ReloadOnChange == true && (!_serverAddresses.PreferHostingUrls || _serverAddresses.InternalCollection.Count == 0)) { - reloadToken = Options.ConfigurationLoader.Configuration.GetReloadToken(); + reloadToken = Options.ConfigurationLoader.GetReloadToken(); } Options.ConfigurationLoader?.LoadInternal(); @@ -340,7 +340,7 @@ private async Task RebindAsync() Debug.Assert(Options.ConfigurationLoader != null, "Rebind can only happen when there is a ConfigurationLoader."); - reloadToken = Options.ConfigurationLoader.Configuration.GetReloadToken(); + reloadToken = Options.ConfigurationLoader.GetReloadToken(); var (endpointsToStop, endpointsToStart) = Options.ConfigurationLoader.Reload(); Trace.LogDebug("Config reload token fired. Checking for changes..."); diff --git a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs index 8104a2e00d4a..9b71c5127fcd 100644 --- a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs +++ b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs @@ -20,15 +20,22 @@ public class KestrelConfigurationLoader { private readonly IHttpsConfigurationService _httpsConfigurationService; + /// + /// Non-null only makes sense if is true. + /// + private readonly CertificatePathWatcher? _certificatePathWatcher; + private bool _loaded; private bool _endpointsToAddProcessed; + // This is not used to trigger reloads but to suppress redundant reloads triggered in other ways private IChangeToken? _reloadToken; internal KestrelConfigurationLoader( KestrelServerOptions options, IConfiguration configuration, IHttpsConfigurationService httpsConfigurationService, + CertificatePathWatcher? certificatePathWatcher, bool reloadOnChange) { Options = options; @@ -39,6 +46,8 @@ internal KestrelConfigurationLoader( ConfigurationReader = new ConfigurationReader(configuration); _httpsConfigurationService = httpsConfigurationService; + _certificatePathWatcher = certificatePathWatcher; + Debug.Assert(reloadOnChange || (certificatePathWatcher is null), "If reloadOnChange is false, then certificatePathWatcher should be null"); } /// @@ -49,7 +58,7 @@ internal KestrelConfigurationLoader( /// /// Gets the application . /// - public IConfiguration Configuration { get; internal set; } + public IConfiguration Configuration { get; internal set; } // Setter internal for testing /// /// If , Kestrel will dynamically update endpoint bindings when configuration changes. @@ -283,19 +292,36 @@ internal void ProcessEndpointsToAdd() } } + internal IChangeToken? GetReloadToken() + { + Debug.Assert(ReloadOnChange); + + var configToken = Configuration.GetReloadToken(); + + if (_certificatePathWatcher is null) + { + return configToken; + } + + var watcherToken = _certificatePathWatcher.GetChangeToken(); + return new CompositeChangeToken(new[] { configToken, watcherToken }); + } + // Adds endpoints from config to KestrelServerOptions.ConfigurationBackedListenOptions and configures some other options. // Any endpoints that were removed from the last time endpoints were loaded are returned. internal (List, List) Reload() { if (ReloadOnChange) { - _reloadToken = Configuration.GetReloadToken(); + _reloadToken = GetReloadToken(); } var endpointsToStop = Options.ConfigurationBackedListenOptions.ToList(); var endpointsToStart = new List(); var endpointsToReuse = new List(); + var oldDefaultCertificateConfig = DefaultCertificateConfig; + DefaultCertificateConfig = null; DefaultCertificate = null; @@ -345,6 +371,7 @@ internal void ProcessEndpointsToAdd() { if (o.EndpointConfig == endpoint) { + Debug.Assert(o.EndpointConfig?.Certificate?.FileHasChanged != true, "Preserving an endpoint with file changes"); matchingBoundEndpoints.Add(o); } } @@ -384,6 +411,75 @@ internal void ProcessEndpointsToAdd() Options.ConfigurationBackedListenOptions.AddRange(endpointsToReuse); Options.ConfigurationBackedListenOptions.AddRange(endpointsToStart); + if (ReloadOnChange && _certificatePathWatcher is not null) + { + var certificateConfigsToRemove = new List(); + var certificateConfigsToAdd = new List(); + + if (DefaultCertificateConfig != oldDefaultCertificateConfig) + { + if (DefaultCertificateConfig?.IsFileCert == true) + { + certificateConfigsToAdd.Add(DefaultCertificateConfig); + } + + if (oldDefaultCertificateConfig is not null) + { + certificateConfigsToRemove.Add(oldDefaultCertificateConfig); + } + } + + foreach (var endpointToStart in endpointsToStart) + { + var endpointConfig = endpointToStart.EndpointConfig; + if (endpointConfig is null) + { + continue; + } + + var certConfig = endpointConfig.Certificate; + if (certConfig?.IsFileCert == true) + { + certificateConfigsToAdd.Add(certConfig); + } + + foreach (var sniConfig in endpointConfig.Sni.Values) + { + var sniCertConfig = sniConfig.Certificate; + if (sniCertConfig?.IsFileCert == true) + { + certificateConfigsToAdd.Add(sniCertConfig); + } + } + } + + foreach (var endpointToStop in endpointsToStop) + { + var endpointConfig = endpointToStop.EndpointConfig; + if (endpointConfig is null) + { + continue; + } + + var certConfig = endpointConfig.Certificate; + if (certConfig?.IsFileCert == true) + { + certificateConfigsToRemove.Add(certConfig); + } + + foreach (var sniConfig in endpointConfig.Sni.Values) + { + var sniCertConfig = sniConfig.Certificate; + if (sniCertConfig?.IsFileCert == true) + { + certificateConfigsToRemove.Add(sniCertConfig); + } + } + } + + _certificatePathWatcher.UpdateWatches(certificateConfigsToRemove, certificateConfigsToAdd); + } + return (endpointsToStop, endpointsToStart); } } diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 175c208efc9e..c2670be70067 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -28,11 +28,14 @@ public class KestrelServerOptions { internal const string DisableHttp1LineFeedTerminatorsSwitchKey = "Microsoft.AspNetCore.Server.Kestrel.DisableHttp1LineFeedTerminators"; private const string FinOnErrorSwitch = "Microsoft.AspNetCore.Server.Kestrel.FinOnError"; + internal const string CertificateFileWatchingSwitch = "Microsoft.AspNetCore.Server.Kestrel.DisableCertificateFileWatching"; private static readonly bool _finOnError; + private static readonly bool _disableCertificateFileWatching; static KestrelServerOptions() { AppContext.TryGetSwitch(FinOnErrorSwitch, out _finOnError); + AppContext.TryGetSwitch(CertificateFileWatchingSwitch, out _disableCertificateFileWatching); } // internal to fast-path header decoding when RequestHeaderEncodingSelector is unchanged. @@ -443,7 +446,12 @@ public KestrelConfigurationLoader Configure(IConfiguration config, bool reloadOn } var httpsConfigurationService = ApplicationServices.GetRequiredService(); - var loader = new KestrelConfigurationLoader(this, config, httpsConfigurationService, reloadOnChange); + var certificatePathWatcher = reloadOnChange && !_disableCertificateFileWatching + ? new CertificatePathWatcher( + ApplicationServices.GetRequiredService(), + ApplicationServices.GetRequiredService>()) + : null; + var loader = new KestrelConfigurationLoader(this, config, httpsConfigurationService, certificatePathWatcher, reloadOnChange); ConfigurationLoader = loader; return loader; } diff --git a/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs b/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs new file mode 100644 index 000000000000..b4be5350f81c --- /dev/null +++ b/src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs @@ -0,0 +1,602 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; + +public class CertificatePathWatcherTests : LoggedTest +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddAndRemoveWatch(bool absoluteFilePath) + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + + var changeToken = watcher.GetChangeToken(); + + var certificateConfig = new CertificateConfig + { + Path = absoluteFilePath ? filePath : fileName, + }; + + IDictionary messageProps; + + watcher.AddWatchUnsynchronized(certificateConfig); + + messageProps = GetLogMessageProperties(TestSink, "CreatedDirectoryWatcher"); + Assert.Equal(dir, messageProps["Directory"]); + + messageProps = GetLogMessageProperties(TestSink, "CreatedFileWatcher"); + Assert.Equal(filePath, messageProps["Path"]); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + watcher.RemoveWatchUnsynchronized(certificateConfig); + + messageProps = GetLogMessageProperties(TestSink, "RemovedFileWatcher"); + Assert.Equal(filePath, messageProps["Path"]); + + messageProps = GetLogMessageProperties(TestSink, "RemovedDirectoryWatcher"); + Assert.Equal(dir, messageProps["Directory"]); + + Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); + + Assert.Same(changeToken, watcher.GetChangeToken()); + Assert.False(changeToken.HasChanged); + } + + [Theory] + [InlineData(2, 4)] + [InlineData(5, 3)] + [InlineData(5, 13)] + public void WatchMultipleDirectories(int dirCount, int fileCount) + { + var rootDir = Directory.GetCurrentDirectory(); + var dirs = new string[dirCount]; + + var logger = LoggerFactory.CreateLogger(); + + for (int i = 0; i < dirCount; i++) + { + dirs[i] = Path.Combine(rootDir, $"dir{i}"); + } + + using var watcher = new CertificatePathWatcher(rootDir, logger, _ => NoChangeFileProvider.Instance); + + var certificateConfigs = new CertificateConfig[fileCount]; + var filesInDir = new int[dirCount]; + for (int i = 0; i < fileCount; i++) + { + certificateConfigs[i] = new CertificateConfig + { + Path = $"dir{i % dirCount}/file{i % fileCount}", + }; + filesInDir[i % dirCount]++; + } + + foreach (var certificateConfig in certificateConfigs) + { + watcher.AddWatchUnsynchronized(certificateConfig); + } + + Assert.Equal(Math.Min(dirCount, fileCount), watcher.TestGetDirectoryWatchCountUnsynchronized()); + + for (int i = 0; i < dirCount; i++) + { + Assert.Equal(filesInDir[i], watcher.TestGetFileWatchCountUnsynchronized(dirs[i])); + } + + foreach (var certificateConfig in certificateConfigs) + { + watcher.RemoveWatchUnsynchronized(certificateConfig); + } + + Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + public async Task FileChanged(int observerCount) + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + var fileProvider = new MockFileProvider(); + var fileLastModifiedTime = DateTimeOffset.UtcNow; + fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + + var signalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var oldChangeToken = watcher.GetChangeToken(); + oldChangeToken.RegisterChangeCallback(_ => signalTcs.SetResult(), state: null); + + var certificateConfigs = new CertificateConfig[observerCount]; + for (int i = 0; i < observerCount; i++) + { + certificateConfigs[i] = new CertificateConfig + { + Path = filePath, + }; + + watcher.AddWatchUnsynchronized(certificateConfigs[i]); + } + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(observerCount, watcher.TestGetObserverCountUnsynchronized(filePath)); + + // Simulate file change on disk + fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime.AddSeconds(1)); + fileProvider.FireChangeToken(fileName); + + await signalTcs.Task.DefaultTimeout(); + + var newChangeToken = watcher.GetChangeToken(); + + Assert.NotSame(oldChangeToken, newChangeToken); + Assert.True(oldChangeToken.HasChanged); + Assert.False(newChangeToken.HasChanged); + + Assert.All(certificateConfigs, cc => Assert.True(cc.FileHasChanged)); + } + + [Fact] + public async Task OutOfOrderLastModifiedTime() + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + var fileProvider = new MockFileProvider(); + var fileLastModifiedTime = DateTimeOffset.UtcNow; + fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + + var certificateConfig = new CertificateConfig + { + Path = filePath, + }; + + watcher.AddWatchUnsynchronized(certificateConfig); + + var logTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + TestSink.MessageLogged += writeContext => + { + if (writeContext.EventId.Name == "OutOfOrderEvent") + { + logTcs.SetResult(); + } + }; + + var oldChangeToken = watcher.GetChangeToken(); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + // Simulate file change on disk + fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime.AddSeconds(-1)); + fileProvider.FireChangeToken(fileName); + + await logTcs.Task.DefaultTimeout(); + + Assert.False(oldChangeToken.HasChanged); + } + + [Fact] + public void DirectoryDoesNotExist() + { + var dir = Path.Combine(Directory.GetCurrentDirectory(), Path.GetRandomFileName()); + + Assert.False(Directory.Exists(dir)); + + var logger = LoggerFactory.CreateLogger(); + + // Returning null indicates that the directory does not exist + using var watcher = new CertificatePathWatcher(dir, logger, _ => null); + + var certificateConfig = new CertificateConfig + { + Path = Path.Combine(dir, "test.pfx"), + }; + + watcher.AddWatchUnsynchronized(certificateConfig); + + var messageProps = GetLogMessageProperties(TestSink, "DirectoryDoesNotExist"); + Assert.Equal(dir, messageProps["Directory"]); + + Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RemoveUnknownFileWatch(bool previouslyAdded) + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + + var certificateConfig = new CertificateConfig + { + Path = filePath, + }; + + if (previouslyAdded) + { + watcher.AddWatchUnsynchronized(certificateConfig); + watcher.RemoveWatchUnsynchronized(certificateConfig); + } + + Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); + + watcher.RemoveWatchUnsynchronized(certificateConfig); + + var messageProps = GetLogMessageProperties(TestSink, "UnknownFile"); + Assert.Equal(filePath, messageProps["Path"]); + + Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RemoveUnknownFileObserver(bool previouslyAdded) + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + + var certificateConfig1 = new CertificateConfig + { + Path = filePath, + }; + + var certificateConfig2 = new CertificateConfig + { + Path = filePath, + }; + + watcher.AddWatchUnsynchronized(certificateConfig1); + + if (previouslyAdded) + { + watcher.AddWatchUnsynchronized(certificateConfig2); + watcher.RemoveWatchUnsynchronized(certificateConfig2); + } + + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + watcher.RemoveWatchUnsynchronized(certificateConfig2); + + var messageProps = GetLogMessageProperties(TestSink, "UnknownObserver"); + Assert.Equal(filePath, messageProps["Path"]); + + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + } + + [Fact] + [LogLevel(LogLevel.Trace)] + public void ReuseFileObserver() + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + + var certificateConfig = new CertificateConfig + { + Path = filePath, + }; + + watcher.AddWatchUnsynchronized(certificateConfig); + + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + watcher.AddWatchUnsynchronized(certificateConfig); + + var messageProps = GetLogMessageProperties(TestSink, "ReusedObserver"); + Assert.Equal(filePath, messageProps["Path"]); + + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + [LogLevel(LogLevel.Trace)] + public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNewerLastModifiedTime) + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + var fileProvider = new MockFileProvider(); + var fileLastModifiedTime = DateTimeOffset.UtcNow; + fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => fileProvider); + + var certificateConfig = new CertificateConfig + { + Path = filePath, + }; + + watcher.AddWatchUnsynchronized(certificateConfig); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + var changeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + watcher.GetChangeToken().RegisterChangeCallback(_ => changeTcs.SetResult(), state: null); + + var logNoLastModifiedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var logSameLastModifiedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + TestSink.MessageLogged += writeContext => + { + if (writeContext.EventId.Name == "EventWithoutLastModifiedTime") + { + logNoLastModifiedTcs.SetResult(); + } + else if (writeContext.EventId.Name == "RedundantEvent") + { + logSameLastModifiedTcs.SetResult(); + } + }; + + // Simulate file deletion + fileProvider.SetLastModifiedTime(fileName, null); + + // In some file systems and watch modes, there's no event when (e.g.) the directory containing the watched file is deleted + if (seeChangeForDeletion) + { + fileProvider.FireChangeToken(fileName); + + await logNoLastModifiedTcs.Task.DefaultTimeout(); + } + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + Assert.False(changeTcs.Task.IsCompleted); + + // Restore the file + fileProvider.SetLastModifiedTime(fileName, restoredWithNewerLastModifiedTime ? fileLastModifiedTime.AddSeconds(1) : fileLastModifiedTime); + fileProvider.FireChangeToken(fileName); + + if (restoredWithNewerLastModifiedTime) + { + await changeTcs.Task.DefaultTimeout(); + Assert.False(logSameLastModifiedTcs.Task.IsCompleted); + } + else + { + await logSameLastModifiedTcs.Task.DefaultTimeout(); + Assert.False(changeTcs.Task.IsCompleted); + } + } + + [Fact] + public void UpdateWatches() + { + var dir = Directory.GetCurrentDirectory(); + var fileName = Path.GetRandomFileName(); + var filePath = Path.Combine(dir, fileName); + + var logger = LoggerFactory.CreateLogger(); + + using var watcher = new CertificatePathWatcher(dir, logger, _ => NoChangeFileProvider.Instance); + + var changeToken = watcher.GetChangeToken(); + + var certificateConfig1 = new CertificateConfig + { + Path = filePath, + }; + + var certificateConfig2 = new CertificateConfig + { + Path = filePath, + }; + + var certificateConfig3 = new CertificateConfig + { + Path = filePath, + }; + + // Add certificateConfig1 + watcher.UpdateWatches(new List { }, new List { certificateConfig1 }); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + // Remove certificateConfig1 + watcher.UpdateWatches(new List { certificateConfig1 }, new List { }); + + Assert.Equal(0, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(0, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(0, watcher.TestGetObserverCountUnsynchronized(filePath)); + + // Re-add certificateConfig1 + watcher.UpdateWatches(new List { }, new List { certificateConfig1 }); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(1, watcher.TestGetObserverCountUnsynchronized(filePath)); + + watcher.UpdateWatches( + new List + { + certificateConfig1, // Delete something present + certificateConfig1, // Delete it again + certificateConfig2, // Delete something never added + certificateConfig2, // Delete it again + }, + new List + { + certificateConfig1, // Re-add something removed above + certificateConfig1, // Re-add it again + certificateConfig2, // Add something vacuously removed above + certificateConfig2, // Add it again + certificateConfig3, // Add something new + certificateConfig3, // Add it again + }); + + Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized()); + Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir)); + Assert.Equal(3, watcher.TestGetObserverCountUnsynchronized(filePath)); + } + + private static IDictionary GetLogMessageProperties(ITestSink testSink, string eventName) + { + var writeContext = Assert.Single(testSink.Writes.Where(wc => wc.EventId.Name == eventName)); + var pairs = (IReadOnlyList>)writeContext.State; + var dict = new Dictionary(pairs); + return dict; + } + + private sealed class NoChangeFileProvider : IFileProvider + { + public static readonly IFileProvider Instance = new NoChangeFileProvider(); + + private NoChangeFileProvider() + { + } + + IDirectoryContents IFileProvider.GetDirectoryContents(string subpath) => throw new NotSupportedException(); + IFileInfo IFileProvider.GetFileInfo(string subpath) => throw new NotSupportedException(); + IChangeToken IFileProvider.Watch(string filter) => NoChangeChangeToken.Instance; + + private sealed class NoChangeChangeToken : IChangeToken + { + public static readonly IChangeToken Instance = new NoChangeChangeToken(); + + private NoChangeChangeToken() + { + } + + bool IChangeToken.HasChanged => false; + bool IChangeToken.ActiveChangeCallbacks => true; + IDisposable IChangeToken.RegisterChangeCallback(Action callback, object state) => DummyDisposable.Instance; + } + } + + private sealed class DummyDisposable : IDisposable + { + public static readonly IDisposable Instance = new DummyDisposable(); + + private DummyDisposable() + { + } + + void IDisposable.Dispose() + { + } + } + + private sealed class MockFileProvider : IFileProvider + { + private readonly Dictionary _changeTokens = new(); + private readonly Dictionary _lastModifiedTimes = new(); + + public void FireChangeToken(string path) + { + var oldChangeToken = _changeTokens[path]; + _changeTokens[path] = new ConfigurationReloadToken(); + oldChangeToken.OnReload(); + } + + public void SetLastModifiedTime(string path, DateTimeOffset? lastModifiedTime) + { + _lastModifiedTimes[path] = lastModifiedTime; + } + + IDirectoryContents IFileProvider.GetDirectoryContents(string subpath) + { + throw new NotSupportedException(); + } + + IFileInfo IFileProvider.GetFileInfo(string subpath) + { + return new MockFileInfo(_lastModifiedTimes[subpath]); + } + + IChangeToken IFileProvider.Watch(string path) + { + if (!_changeTokens.TryGetValue(path, out var changeToken)) + { + _changeTokens[path] = changeToken = new ConfigurationReloadToken(); + } + + return changeToken; + } + + private sealed class MockFileInfo : IFileInfo + { + private readonly DateTimeOffset? _lastModifiedTime; + + public MockFileInfo(DateTimeOffset? lastModifiedTime) + { + _lastModifiedTime = lastModifiedTime; + } + + bool IFileInfo.Exists => _lastModifiedTime.HasValue; + DateTimeOffset IFileInfo.LastModified => _lastModifiedTime.GetValueOrDefault(); + + long IFileInfo.Length => throw new NotSupportedException(); + string IFileInfo.PhysicalPath => throw new NotSupportedException(); + string IFileInfo.Name => throw new NotSupportedException(); + bool IFileInfo.IsDirectory => throw new NotSupportedException(); + Stream IFileInfo.CreateReadStream() => throw new NotSupportedException(); + } + } +} diff --git a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs index e13e3717688e..f3f4e3a60afd 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; @@ -760,6 +761,7 @@ public async Task ReloadsOnConfigurationChangeWhenOptedIn() TaskCompletionSource changeCallbackRegisteredTcs = null; var mockChangeToken = new Mock(); + mockChangeToken.Setup(t => t.ActiveChangeCallbacks).Returns(true); mockChangeToken.Setup(t => t.RegisterChangeCallback(It.IsAny>(), It.IsAny())).Returns, object>((callback, state) => { changeCallbackRegisteredTcs?.SetResult(); @@ -786,6 +788,7 @@ public async Task ReloadsOnConfigurationChangeWhenOptedIn() serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of>()); serviceCollection.AddSingleton(Mock.Of()); var options = new KestrelServerOptions diff --git a/src/Servers/Kestrel/Core/test/Microsoft.AspNetCore.Server.Kestrel.Core.Tests.csproj b/src/Servers/Kestrel/Core/test/Microsoft.AspNetCore.Server.Kestrel.Core.Tests.csproj index 34d9d34335dc..638330a77a1d 100644 --- a/src/Servers/Kestrel/Core/test/Microsoft.AspNetCore.Server.Kestrel.Core.Tests.csproj +++ b/src/Servers/Kestrel/Core/test/Microsoft.AspNetCore.Server.Kestrel.Core.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs index 83777bc0e2aa..197128b72907 100644 --- a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs @@ -840,6 +840,88 @@ public void ConfigureEndpoint_DoesNotThrowWhen_HttpsConfigIsDeclaredInEndpointDe Assert.Null(end1.EndpointConfig.SslProtocols); } + [Theory] + [InlineData(true)] // This might be flaky, since it depends on file system events (or polling) + [InlineData(false)] // This will be slow (1 seconds) + public async Task CertificateChangedOnDisk(bool reloadOnChange) + { + var certificatePath = GetCertificatePath(); + + try + { + var serverOptions = CreateServerOptions(); + + var certificatePassword = "1234"; + + var oldCertificate = new X509Certificate2(TestResources.GetCertPath("aspnetdevcert.pfx"), "testPassword", X509KeyStorageFlags.Exportable); + var oldCertificateBytes = oldCertificate.Export(X509ContentType.Pkcs12, certificatePassword); + + var newCertificate = new X509Certificate2(TestResources.TestCertificatePath, "testPassword", X509KeyStorageFlags.Exportable); + var newCertificateBytes = newCertificate.Export(X509ContentType.Pkcs12, certificatePassword); + + Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)); + File.WriteAllBytes(certificatePath, oldCertificateBytes); + + var endpointConfigurationCallCount = 0; + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:End1:Url", "https://*:5001"), + new KeyValuePair("Endpoints:End1:Certificate:Path", certificatePath), + new KeyValuePair("Endpoints:End1:Certificate:Password", certificatePassword), + }).Build(); + + var configLoader = serverOptions + .Configure(config, reloadOnChange) + .Endpoint("End1", opt => + { + Assert.True(opt.IsHttps); + var expectedSerialNumber = endpointConfigurationCallCount == 0 + ? oldCertificate.SerialNumber + : newCertificate.SerialNumber; + Assert.Equal(opt.HttpsOptions.ServerCertificate.SerialNumber, expectedSerialNumber); + endpointConfigurationCallCount++; + }); + + configLoader.Load(); + + var fileTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (reloadOnChange) // There's no reload token if !reloadOnChange + { + configLoader.GetReloadToken().RegisterChangeCallback(_ => fileTcs.SetResult(), state: null); + } + File.WriteAllBytes(certificatePath, newCertificateBytes); + + if (reloadOnChange) + { + await fileTcs.Task.DefaultTimeout(); + } + else + { + // We can't just check immediately that the callback hasn't fired - we might preempt it + await Task.Delay(TimeSpan.FromSeconds(1)); + Assert.False(fileTcs.Task.IsCompleted); + } + + Assert.Equal(1, endpointConfigurationCallCount); + + if (reloadOnChange) + { + configLoader.Reload(); + + Assert.Equal(2, endpointConfigurationCallCount); + } + } + finally + { + if (File.Exists(certificatePath)) + { + // Note: the watcher will see this event, but we ignore deletions, so it shouldn't matter + File.Delete(certificatePath); + } + } + } + [ConditionalTheory] [InlineData("http1", HttpProtocols.Http1)] // [InlineData("http2", HttpProtocols.Http2)] // Not supported due to missing ALPN support. https://github.com/dotnet/corefx/issues/33016 diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index 7d7c61e5a477..12ec2aadd9d2 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -79,7 +79,7 @@ public async Task CanReadAndWriteWithHttpsConnectionMiddlewareWithPemCertificate var options = CreateServerOptions(); - var loader = new KestrelConfigurationLoader(options, configuration, options.ApplicationServices.GetRequiredService(), reloadOnChange: false); + var loader = new KestrelConfigurationLoader(options, configuration, options.ApplicationServices.GetRequiredService(), certificatePathWatcher: null, reloadOnChange: false); options.ConfigurationLoader = loader; // Since we're constructing it explicitly, we have to hook it up explicitly loader.Load();