| File: Internal\CertificatePathWatcher.cs | Web Access |
| Project: src\aspnetcore\src\Servers\Kestrel\Core\src\Microsoft.AspNetCore.Server.Kestrel.Core.csproj (Microsoft.AspNetCore.Server.Kestrel.Core) |
// 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<string, IFileProvider?> _fileProviderFactory; private readonly string _contentRootDir; private readonly ILogger<CertificatePathWatcher> _logger; private readonly Lock _metadataLock = new(); /// <remarks>Acquire <see cref="_metadataLock"/> before accessing.</remarks> private readonly Dictionary<string, DirectoryWatchMetadata> _metadataForDirectory = new(); /// <remarks>Acquire <see cref="_metadataLock"/> before accessing.</remarks> private readonly Dictionary<string, FileWatchMetadata> _metadataForFile = new(); private ConfigurationReloadToken _reloadToken = new(); private bool _disposed; public CertificatePathWatcher(IHostEnvironment hostEnvironment, ILogger<CertificatePathWatcher> logger) : this( hostEnvironment.ContentRootPath, logger, dir => Directory.Exists(dir) ? new PhysicalFileProvider(dir, ExclusionFilters.None) { // Force polling because it monitors both symlinks and their targets, // whereas the non-polling watcher only monitors the symlinks themselves UseActivePolling = true, UsePollingFileWatcher = true, } : null) { } /// <remarks> /// For testing. /// </remarks> internal CertificatePathWatcher(string contentRootPath, ILogger<CertificatePathWatcher> logger, Func<string, IFileProvider?> fileProviderFactory) { _contentRootDir = contentRootPath; _logger = logger; _fileProviderFactory = fileProviderFactory; } /// <summary> /// Returns a token that will fire when any watched <see cref="CertificateConfig"/> is changed on disk. /// The affected <see cref="CertificateConfig"/> will have <see cref="CertificateConfig.FileHasChanged"/> /// set to <code>true</code>. /// </summary> public IChangeToken GetChangeToken() { return _reloadToken; } /// <summary> /// Update the set of <see cref="CertificateConfig"/>s being watched for file changes. /// If a given <see cref="CertificateConfig"/> appears in both lists, it is first removed and then added. /// </summary> /// <remarks> /// Does not consider targets when watching files that are symlinks. /// </remarks> public void UpdateWatches(List<CertificateConfig> certificateConfigsToRemove, List<CertificateConfig> certificateConfigsToAdd) { var addSet = new HashSet<CertificateConfig>(certificateConfigsToAdd, ReferenceEqualityComparer.Instance); var removeSet = new HashSet<CertificateConfig>(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); } } } /// <summary> /// Start watching a certificate's file path for changes. /// <paramref name="certificateConfig"/> must have <see cref="CertificateConfig.IsFileCert"/> set to <code>true</code>. /// </summary> /// <remarks> /// Internal for testing. /// </remarks> 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++; _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 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 result in a file becoming unavailable. // 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. var fileInfo = dirMetadata.FileProvider.GetFileInfo(Path.GetFileName(path)); if (!fileInfo.Exists) { _logger.EventWithoutFile(path); return; } var configs = fileMetadata.Configs; foreach (var config in configs) { config.FileHasChanged = true; } _logger.FlaggedObservers(path, configs.Count); } // 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(); } /// <summary> /// Stop watching a certificate's file path for changes (previously started by <see cref="AddWatchUnsynchronized"/>. /// <paramref name="certificateConfig"/> must have <see cref="CertificateConfig.IsFileCert"/> set to <code>true</code>. /// </summary> /// <remarks> /// Internal for testing. /// </remarks> 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); } /// <remarks>Test hook</remarks> internal int TestGetDirectoryWatchCountUnsynchronized() => _metadataForDirectory.Count; /// <remarks>Test hook</remarks> internal int TestGetFileWatchCountUnsynchronized(string dir) => _metadataForDirectory.TryGetValue(dir, out var metadata) ? metadata.FileWatchCount : 0; /// <remarks>Test hook</remarks> 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<CertificateConfig> Configs = new(ReferenceEqualityComparer.Instance); public void Dispose() => Disposable.Dispose(); } }