Skip to content

Commit 8c7a342

Browse files
authored
Use polling to watch certificate paths (#50251)
1 parent a0fb1dd commit 8c7a342

File tree

5 files changed

+128
-96
lines changed

5 files changed

+128
-96
lines changed

eng/test-configuration.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
{"testName": {"contains": "HEADERS_Received_SecondRequest_ConnectProtocolReset"}},
2323
{"testName": {"contains": "ClientUsingOldCallWithNewProtocol"}},
2424
{"testName": {"contains": "CertificateChangedOnDisk"}},
25+
{"testName": {"contains": "CertificateChangedOnDisk_Symlink"}},
2526
{"testAssembly": {"contains": "IIS"}},
2627
{"testAssembly": {"contains": "Template"}},
2728
{"failureMessage": {"contains":"(Site is started but no worker process found)"}},

src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcher.cs

Lines changed: 18 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,17 @@ internal sealed partial class CertificatePathWatcher : IDisposable
2929

3030
public CertificatePathWatcher(IHostEnvironment hostEnvironment, ILogger<CertificatePathWatcher> logger)
3131
: this(
32-
hostEnvironment.ContentRootPath,
33-
logger,
34-
dir => Directory.Exists(dir) ? new PhysicalFileProvider(dir, ExclusionFilters.None) : null)
32+
hostEnvironment.ContentRootPath,
33+
logger,
34+
dir => Directory.Exists(dir)
35+
? new PhysicalFileProvider(dir, ExclusionFilters.None)
36+
{
37+
// Force polling because it monitors both symlinks and their targets,
38+
// whereas the non-polling watcher only monitors the symlinks themselves
39+
UseActivePolling = true,
40+
UsePollingFileWatcher = true,
41+
}
42+
: null)
3543
{
3644
}
3745

@@ -138,9 +146,6 @@ internal void AddWatchUnsynchronized(CertificateConfig certificateConfig)
138146
_metadataForFile.Add(path, fileMetadata);
139147
dirMetadata.FileWatchCount++;
140148

141-
// We actually don't care if the file doesn't exist - we'll watch in case it is created
142-
fileMetadata.LastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider);
143-
144149
_logger.CreatedFileWatcher(path);
145150
}
146151

@@ -156,20 +161,6 @@ internal void AddWatchUnsynchronized(CertificateConfig certificateConfig)
156161
_logger.FileCount(dir, dirMetadata.FileWatchCount);
157162
}
158163

159-
private DateTimeOffset GetLastModifiedTimeOrMinimum(string path, IFileProvider fileProvider)
160-
{
161-
try
162-
{
163-
return fileProvider.GetFileInfo(Path.GetFileName(path)).LastModified;
164-
}
165-
catch (Exception e)
166-
{
167-
_logger.LastModifiedTimeError(path, e);
168-
}
169-
170-
return DateTimeOffset.MinValue;
171-
}
172-
173164
private void OnChange(string path)
174165
{
175166
// Block until any in-progress updates are complete
@@ -184,33 +175,17 @@ private void OnChange(string path)
184175
// Existence implied by the fact that we're tracking the file
185176
var dirMetadata = _metadataForDirectory[Path.GetDirectoryName(path)!];
186177

187-
// We ignore file changes that don't advance the last modified time.
178+
// We ignore file changes that result in a file becoming unavailable.
188179
// For example, if we lose access to the network share the file is
189180
// stored on, we don't notify our listeners because no one wants
190181
// their endpoint/server to shutdown when that happens.
191182
// We also anticipate that a cert file might be renamed to cert.bak
192183
// before a new cert is introduced with the old name.
193-
// This also helps us in scenarios where the underlying file system
194-
// reports more than one change for a single logical operation.
195-
var lastModifiedTime = GetLastModifiedTimeOrMinimum(path, dirMetadata.FileProvider);
196-
if (lastModifiedTime > fileMetadata.LastModifiedTime)
197-
{
198-
fileMetadata.LastModifiedTime = lastModifiedTime;
199-
}
200-
else
184+
185+
var fileInfo = dirMetadata.FileProvider.GetFileInfo(Path.GetFileName(path));
186+
if (!fileInfo.Exists)
201187
{
202-
if (lastModifiedTime == DateTimeOffset.MinValue)
203-
{
204-
_logger.EventWithoutLastModifiedTime(path);
205-
}
206-
else if (lastModifiedTime == fileMetadata.LastModifiedTime)
207-
{
208-
_logger.RedundantEvent(path);
209-
}
210-
else
211-
{
212-
_logger.OutOfOrderEvent(path);
213-
}
188+
_logger.EventWithoutFile(path);
214189
return;
215190
}
216191

@@ -219,6 +194,8 @@ private void OnChange(string path)
219194
{
220195
config.FileHasChanged = true;
221196
}
197+
198+
_logger.FlaggedObservers(path, configs.Count);
222199
}
223200

224201
// AddWatch and RemoveWatch don't affect the token, so this doesn't need to be under the semaphore.
@@ -321,7 +298,6 @@ private sealed class FileWatchMetadata(IDisposable disposable) : IDisposable
321298
{
322299
public readonly IDisposable Disposable = disposable;
323300
public readonly HashSet<CertificateConfig> Configs = new(ReferenceEqualityComparer.Instance);
324-
public DateTimeOffset LastModifiedTime = DateTimeOffset.MinValue;
325301

326302
public void Dispose() => Disposable.Dispose();
327303
}

src/Servers/Kestrel/Core/src/Internal/CertificatePathWatcherLoggerExtensions.cs

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,30 +34,24 @@ internal static partial class CertificatePathWatcherLoggerExtensions
3434
[LoggerMessage(9, LogLevel.Debug, "Ignored event for presently untracked file '{Path}'.", EventName = "UntrackedFileEvent")]
3535
public static partial void UntrackedFileEvent(this ILogger<CertificatePathWatcher> logger, string path);
3636

37-
[LoggerMessage(10, LogLevel.Debug, "Ignored out-of-order event for file '{Path}'.", EventName = "OutOfOrderEvent")]
38-
public static partial void OutOfOrderEvent(this ILogger<CertificatePathWatcher> logger, string path);
39-
40-
[LoggerMessage(11, LogLevel.Trace, "Reused existing observer on file watcher for '{Path}'.", EventName = "ReusedObserver")]
37+
[LoggerMessage(10, LogLevel.Trace, "Reused existing observer on file watcher for '{Path}'.", EventName = "ReusedObserver")]
4138
public static partial void ReusedObserver(this ILogger<CertificatePathWatcher> logger, string path);
4239

43-
[LoggerMessage(12, LogLevel.Trace, "Added observer to file watcher for '{Path}'.", EventName = "AddedObserver")]
40+
[LoggerMessage(11, LogLevel.Trace, "Added observer to file watcher for '{Path}'.", EventName = "AddedObserver")]
4441
public static partial void AddedObserver(this ILogger<CertificatePathWatcher> logger, string path);
4542

46-
[LoggerMessage(13, LogLevel.Trace, "Removed observer from file watcher for '{Path}'.", EventName = "RemovedObserver")]
43+
[LoggerMessage(12, LogLevel.Trace, "Removed observer from file watcher for '{Path}'.", EventName = "RemovedObserver")]
4744
public static partial void RemovedObserver(this ILogger<CertificatePathWatcher> logger, string path);
4845

49-
[LoggerMessage(14, LogLevel.Trace, "File '{Path}' now has {Count} observers.", EventName = "ObserverCount")]
46+
[LoggerMessage(13, LogLevel.Trace, "File '{Path}' now has {Count} observers.", EventName = "ObserverCount")]
5047
public static partial void ObserverCount(this ILogger<CertificatePathWatcher> logger, string path, int count);
5148

52-
[LoggerMessage(15, LogLevel.Trace, "Directory '{Directory}' now has watchers on {Count} files.", EventName = "FileCount")]
49+
[LoggerMessage(14, LogLevel.Trace, "Directory '{Directory}' now has watchers on {Count} files.", EventName = "FileCount")]
5350
public static partial void FileCount(this ILogger<CertificatePathWatcher> logger, string directory, int count);
5451

55-
[LoggerMessage(16, LogLevel.Trace, "Ignored event since last modified time for '{Path}' was unavailable.", EventName = "EventWithoutLastModifiedTime")]
56-
public static partial void EventWithoutLastModifiedTime(this ILogger<CertificatePathWatcher> logger, string path);
57-
58-
[LoggerMessage(17, LogLevel.Trace, "Ignored redundant event for '{Path}'.", EventName = "RedundantEvent")]
59-
public static partial void RedundantEvent(this ILogger<CertificatePathWatcher> logger, string path);
60-
61-
[LoggerMessage(18, LogLevel.Trace, "Flagged {Count} observers of '{Path}' as changed.", EventName = "FlaggedObservers")]
52+
[LoggerMessage(15, LogLevel.Trace, "Flagged {Count} observers of '{Path}' as changed.", EventName = "FlaggedObservers")]
6253
public static partial void FlaggedObservers(this ILogger<CertificatePathWatcher> logger, string path, int count);
54+
55+
[LoggerMessage(16, LogLevel.Trace, "Ignored event since '{Path}' was unavailable.", EventName = "EventWithoutFile")]
56+
public static partial void EventWithoutFile(this ILogger<CertificatePathWatcher> logger, string path);
6357
}

src/Servers/Kestrel/Core/test/CertificatePathWatcherTests.cs

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,10 @@ public async Task OutOfOrderLastModifiedTime()
187187

188188
watcher.AddWatchUnsynchronized(certificateConfig);
189189

190-
var logTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
191-
192-
TestSink.MessageLogged += writeContext =>
193-
{
194-
if (writeContext.EventId.Name == "OutOfOrderEvent")
195-
{
196-
logTcs.SetResult();
197-
}
198-
};
190+
var signalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
199191

200192
var oldChangeToken = watcher.GetChangeToken();
193+
oldChangeToken.RegisterChangeCallback(_ => signalTcs.SetResult(), state: null);
201194

202195
Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized());
203196
Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir));
@@ -207,9 +200,9 @@ public async Task OutOfOrderLastModifiedTime()
207200
fileProvider.SetLastModifiedTime(fileName, fileLastModifiedTime.AddSeconds(-1));
208201
fileProvider.FireChangeToken(fileName);
209202

210-
await logTcs.Task.DefaultTimeout();
203+
await signalTcs.Task.DefaultTimeout();
211204

212-
Assert.False(oldChangeToken.HasChanged);
205+
Assert.True(oldChangeToken.HasChanged);
213206
}
214207

215208
[Fact]
@@ -342,12 +335,10 @@ public void ReuseFileObserver()
342335
}
343336

344337
[Theory]
345-
[InlineData(true, true)]
346-
[InlineData(true, false)]
347-
[InlineData(false, true)]
348-
[InlineData(false, false)]
338+
[InlineData(true)]
339+
[InlineData(false)]
349340
[LogLevel(LogLevel.Trace)]
350-
public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNewerLastModifiedTime)
341+
public async Task IgnoreDeletion(bool restoredWithNewerLastModifiedTime)
351342
{
352343
var dir = Directory.GetCurrentDirectory();
353344
var fileName = Path.GetRandomFileName();
@@ -377,30 +368,21 @@ public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNew
377368
watcher.GetChangeToken().RegisterChangeCallback(_ => changeTcs.SetResult(), state: null);
378369

379370
var logNoLastModifiedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
380-
var logSameLastModifiedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
381371

382372
TestSink.MessageLogged += writeContext =>
383373
{
384-
if (writeContext.EventId.Name == "EventWithoutLastModifiedTime")
374+
if (writeContext.EventId.Name == "EventWithoutFile")
385375
{
386376
logNoLastModifiedTcs.SetResult();
387377
}
388-
else if (writeContext.EventId.Name == "RedundantEvent")
389-
{
390-
logSameLastModifiedTcs.SetResult();
391-
}
392378
};
393379

394380
// Simulate file deletion
395381
fileProvider.SetLastModifiedTime(fileName, null);
396382

397-
// In some file systems and watch modes, there's no event when (e.g.) the directory containing the watched file is deleted
398-
if (seeChangeForDeletion)
399-
{
400-
fileProvider.FireChangeToken(fileName);
383+
fileProvider.FireChangeToken(fileName);
401384

402-
await logNoLastModifiedTcs.Task.DefaultTimeout();
403-
}
385+
await logNoLastModifiedTcs.Task.DefaultTimeout();
404386

405387
Assert.Equal(1, watcher.TestGetDirectoryWatchCountUnsynchronized());
406388
Assert.Equal(1, watcher.TestGetFileWatchCountUnsynchronized(dir));
@@ -412,16 +394,7 @@ public async Task IgnoreDeletion(bool seeChangeForDeletion, bool restoredWithNew
412394
fileProvider.SetLastModifiedTime(fileName, restoredWithNewerLastModifiedTime ? fileLastModifiedTime.AddSeconds(1) : fileLastModifiedTime);
413395
fileProvider.FireChangeToken(fileName);
414396

415-
if (restoredWithNewerLastModifiedTime)
416-
{
417-
await changeTcs.Task.DefaultTimeout();
418-
Assert.False(logSameLastModifiedTcs.Task.IsCompleted);
419-
}
420-
else
421-
{
422-
await logSameLastModifiedTcs.Task.DefaultTimeout();
423-
Assert.False(changeTcs.Task.IsCompleted);
424-
}
397+
await changeTcs.Task.DefaultTimeout();
425398
}
426399

427400
[Fact]

src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,7 @@ public async Task CertificateChangedOnDisk(bool reloadOnChange)
894894

895895
if (reloadOnChange)
896896
{
897-
await fileTcs.Task.DefaultTimeout();
897+
await fileTcs.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); // Needs to be meaningfully longer than the polling period - 4 seconds
898898
}
899899
else
900900
{
@@ -922,6 +922,94 @@ public async Task CertificateChangedOnDisk(bool reloadOnChange)
922922
}
923923
}
924924

925+
[ConditionalFact]
926+
[OSSkipCondition(OperatingSystems.Windows)] // Windows has poor support for directory symlinks (e.g. https://github.com/dotnet/runtime/issues/27826)
927+
public async Task CertificateChangedOnDisk_Symlink()
928+
{
929+
var tempDir = Directory.CreateTempSubdirectory().FullName;
930+
931+
try
932+
{
933+
// temp/
934+
// tls.key -> link/tls.key
935+
// link/ -> old/
936+
// old/
937+
// tls.key
938+
// new/
939+
// tls.key
940+
941+
var oldDir = Directory.CreateDirectory(Path.Combine(tempDir, "old"));
942+
var newDir = Directory.CreateDirectory(Path.Combine(tempDir, "new"));
943+
var oldCertPath = Path.Combine(oldDir.FullName, "tls.key");
944+
var newCertPath = Path.Combine(newDir.FullName, "tls.key");
945+
946+
var dirLink = Directory.CreateSymbolicLink(Path.Combine(tempDir, "link"), "./old");
947+
var fileLink = File.CreateSymbolicLink(Path.Combine(tempDir, "tls.key"), "./link/tls.key");
948+
949+
var serverOptions = CreateServerOptions();
950+
951+
var certificatePassword = "1234";
952+
953+
var oldCertificate = new X509Certificate2(TestResources.GetCertPath("aspnetdevcert.pfx"), "testPassword", X509KeyStorageFlags.Exportable);
954+
var oldCertificateBytes = oldCertificate.Export(X509ContentType.Pkcs12, certificatePassword);
955+
956+
File.WriteAllBytes(oldCertPath, oldCertificateBytes);
957+
958+
var newCertificate = new X509Certificate2(TestResources.TestCertificatePath, "testPassword", X509KeyStorageFlags.Exportable);
959+
var newCertificateBytes = newCertificate.Export(X509ContentType.Pkcs12, certificatePassword);
960+
961+
File.WriteAllBytes(newCertPath, newCertificateBytes);
962+
963+
var endpointConfigurationCallCount = 0;
964+
var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
965+
{
966+
new KeyValuePair<string, string>("Endpoints:End1:Url", "https://*:5001"),
967+
new KeyValuePair<string, string>("Endpoints:End1:Certificate:Path", fileLink.FullName),
968+
new KeyValuePair<string, string>("Endpoints:End1:Certificate:Password", certificatePassword),
969+
}).Build();
970+
971+
var configLoader = serverOptions
972+
.Configure(config, reloadOnChange: true)
973+
.Endpoint("End1", opt =>
974+
{
975+
Assert.True(opt.IsHttps);
976+
var expectedSerialNumber = endpointConfigurationCallCount == 0
977+
? oldCertificate.SerialNumber
978+
: newCertificate.SerialNumber;
979+
Assert.Equal(opt.HttpsOptions.ServerCertificate.SerialNumber, expectedSerialNumber);
980+
endpointConfigurationCallCount++;
981+
});
982+
983+
configLoader.Load();
984+
985+
var fileTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
986+
987+
configLoader.GetReloadToken().RegisterChangeCallback(_ => fileTcs.SetResult(), state: null);
988+
989+
// Clobber link/ directory symlink - this will effectively cause the cert to be updated.
990+
// Unfortunately, it throws (file exists) if we don't delete the old one first so it's not a single, clean FS operation.
991+
dirLink.Delete();
992+
dirLink = Directory.CreateSymbolicLink(Path.Combine(tempDir, "link"), "./new");
993+
994+
// This can fail in local runs where the timeout is 5 seconds and polling period is 4 seconds - just re-run
995+
await fileTcs.Task.DefaultTimeout();
996+
997+
Assert.Equal(1, endpointConfigurationCallCount);
998+
999+
configLoader.Reload();
1000+
1001+
Assert.Equal(2, endpointConfigurationCallCount);
1002+
}
1003+
finally
1004+
{
1005+
if (Directory.Exists(tempDir))
1006+
{
1007+
// Note: the watcher will see this event, but we ignore deletions, so it shouldn't matter
1008+
Directory.Delete(tempDir, recursive: true);
1009+
}
1010+
}
1011+
}
1012+
9251013
[ConditionalTheory]
9261014
[InlineData("http1", HttpProtocols.Http1)]
9271015
// [InlineData("http2", HttpProtocols.Http2)] // Not supported due to missing ALPN support. https://github.com/dotnet/corefx/issues/33016

0 commit comments

Comments
 (0)