From e9f567baad83231400d3ce0f10115d318dcf7fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Bi=C4=8Dan?= Date: Tue, 1 Oct 2019 23:09:59 +0200 Subject: [PATCH 1/5] #11 Implement certificate renewal. (first attempt, not tested) --- src/LetsEncrypt/Internal/CertificateSelector.cs | 5 +++++ src/LetsEncrypt/LetsEncryptOptions.cs | 11 +++++++++++ src/LetsEncrypt/PublicAPI.Shipped.txt | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/src/LetsEncrypt/Internal/CertificateSelector.cs b/src/LetsEncrypt/Internal/CertificateSelector.cs index 7ca5c5e4..5938adcf 100644 --- a/src/LetsEncrypt/Internal/CertificateSelector.cs +++ b/src/LetsEncrypt/Internal/CertificateSelector.cs @@ -63,5 +63,10 @@ public void Reset(string domainName) { _certs.TryRemove(domainName, out var _); } + + public bool TryGet(string domainName, out X509Certificate2? certificate) + { + return _certs.TryGetValue(domainName, out certificate); + } } } diff --git a/src/LetsEncrypt/LetsEncryptOptions.cs b/src/LetsEncrypt/LetsEncryptOptions.cs index 59113690..efec1af4 100644 --- a/src/LetsEncrypt/LetsEncryptOptions.cs +++ b/src/LetsEncrypt/LetsEncryptOptions.cs @@ -80,6 +80,17 @@ internal Uri GetAcmeServer(IHostEnvironment env) public X509Certificate2? FallbackCertificate { get; set; } /// + /// How long before certificate expiration will be renewal attempted + /// + public TimeSpan? RenewDaysInAdvance { get; set; } = TimeSpan.FromDays(30); + + /// + /// How often will be certificates checked for renewal + /// + public TimeSpan? RenewalCheckPeriod { get; set; } = TimeSpan.FromDays(1); + + /// + /// The uri to the server that implements the ACME protocol for certificate generation. /// Asymetric encryption algorithm: RS256, ES256, ES384, ES512 /// public KeyAlgorithm KeyAlgorithm { get; set; } = KeyAlgorithm.ES256; diff --git a/src/LetsEncrypt/PublicAPI.Shipped.txt b/src/LetsEncrypt/PublicAPI.Shipped.txt index 37912363..92595f8b 100644 --- a/src/LetsEncrypt/PublicAPI.Shipped.txt +++ b/src/LetsEncrypt/PublicAPI.Shipped.txt @@ -22,6 +22,10 @@ McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.FallbackCertificate.set -> vo McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.KeyAlgorithm.get -> McMaster.AspNetCore.LetsEncrypt.KeyAlgorithm McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.KeyAlgorithm.set -> void McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.LetsEncryptOptions() -> void +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.get -> System.TimeSpan? +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.set -> void +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.get -> System.TimeSpan? +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.set -> void McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.UseStagingServer.get -> bool McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.UseStagingServer.set -> void Microsoft.Extensions.DependencyInjection.LetsEncryptServiceCollectionExtensions From 79e211115ac2ca119c06ee5725b00dda8bfcfbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Bi=C4=8Dan?= Date: Fri, 8 May 2020 13:21:54 +0200 Subject: [PATCH 2/5] #11 refactor Acme certificate renewal after rebasing --- .../Internal/AcmeCertificateLoader.cs | 84 ++++++++++++++----- src/LetsEncrypt/PublicAPI.Shipped.txt | 4 - src/LetsEncrypt/PublicAPI.Unshipped.txt | 4 + 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs b/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs index e24d848b..1c0a506f 100644 --- a/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs +++ b/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs @@ -24,7 +24,7 @@ namespace McMaster.AspNetCore.LetsEncrypt.Internal /// /// Loads certificates for all configured hostnames /// - internal class AcmeCertificateLoader : IHostedService + internal class AcmeCertificateLoader : BackgroundService { private readonly CertificateSelector _selector; private readonly IHttpChallengeResponseStore _challengeStore; @@ -37,7 +37,8 @@ internal class AcmeCertificateLoader : IHostedService private readonly IConfiguration _config; private readonly TermsOfServiceChecker _tosChecker; private readonly IEnumerable _certificateRepositories; - private volatile bool _hasRegistered; + + private const string ErrorMessage = "Failed to create certificate"; public AcmeCertificateLoader( CertificateSelector selector, @@ -63,23 +64,20 @@ public AcmeCertificateLoader( _certificateRepositories = certificateRepositories; } - public Task StopAsync(CancellationToken cancellationToken) - => Task.CompletedTask; - - public Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!(_server is KestrelServer)) { var serverType = _server.GetType().FullName; _logger.LogWarning("LetsEncrypt can only be used with Kestrel and is not supported on {serverType} servers. Skipping certificate provisioning.", serverType); - return Task.CompletedTask; + return; } if (_config.GetValue("UseIISIntegration")) { _logger.LogWarning("LetsEncrypt does not work with apps hosting in IIS. IIS does not allow for dynamic HTTPS certificate binding, " + "so if you want to use Let's Encrypt, you'll need to use a different tool to do so."); - return Task.CompletedTask; + return; } // load certificates in the background @@ -87,16 +85,14 @@ public Task StartAsync(CancellationToken cancellationToken) if (!LetsEncryptDomainNamesWereConfigured()) { _logger.LogInformation("No domain names were configured for Let's Encrypt"); - return Task.CompletedTask; + return; } - Task.Factory.StartNew(async () => + await Task.Run(async () => { - const string ErrorMessage = "Failed to create certificate"; - try { - await LoadCerts(cancellationToken); + await LoadCerts(stoppingToken); } catch (AggregateException ex) when (ex.InnerException != null) { @@ -106,9 +102,9 @@ public Task StartAsync(CancellationToken cancellationToken) { _logger.LogError(0, ex, ErrorMessage); } - }); - return Task.CompletedTask; + await MonitorRenewal(stoppingToken); + }); } private bool LetsEncryptDomainNamesWereConfigured() @@ -130,6 +126,11 @@ private async Task LoadCerts(CancellationToken cancellationToken) return; } + await CreateCertificateAsync(domainNames, cancellationToken); + } + + private async Task CreateCertificateAsync(string[] domainNames, CancellationToken cancellationToken) + { var factory = new CertificateFactory( _tosChecker, _options, @@ -138,13 +139,8 @@ private async Task LoadCerts(CancellationToken cancellationToken) _logger, _hostEnvironment); - if (!_hasRegistered) - { - _hasRegistered = true; - - var account = await factory.GetOrCreateAccountAsync(cancellationToken); - _logger.LogInformation("Using Let's Encrypt account {accountId}", account.Id); - } + var account = await factory.GetOrCreateAccountAsync(cancellationToken); + _logger.LogInformation("Using Let's Encrypt account {accountId}", account.Id); try { @@ -152,7 +148,6 @@ private async Task LoadCerts(CancellationToken cancellationToken) domainNames, factory.AcmeServer); - var cert = await factory.CreateCertificateAsync(cancellationToken); _logger.LogInformation("Created certificate {subjectName} ({thumbprint})", cert.Subject, cert.Thumbprint); @@ -182,5 +177,48 @@ private async Task SaveCertificateAsync(X509Certificate2 cert, CancellationToken await Task.WhenAll(saveTasks); } + + private async Task MonitorRenewal(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var checkPeriod = _options.Value.RenewalCheckPeriod; + var daysInAdvance = _options.Value.RenewDaysInAdvance; + if (!checkPeriod.HasValue || !daysInAdvance.HasValue) + { + _logger.LogInformation("Automatic Let's Encrypt certificate renewal is not configured. Stopping {service}", + nameof(AcmeCertificateLoader)); + return; + } + + try + { + var domainNames = _options.Value.DomainNames; + _logger.LogDebug("Checking certificates' renewals for {hostname}", + domainNames); + + foreach (var domainName in domainNames) + { + if (!_selector.TryGet(domainName, out var cert) + || cert == null + || cert.NotAfter <= DateTime.Now + daysInAdvance.Value) + { + await CreateCertificateAsync(domainNames, cancellationToken); + break; + } + } + } + catch (AggregateException ex) when (ex.InnerException != null) + { + _logger.LogError(0, ex.InnerException, ErrorMessage); + } + catch (Exception ex) + { + _logger.LogError(0, ex, ErrorMessage); + } + + await Task.Delay(checkPeriod.Value, cancellationToken); + } + } } } diff --git a/src/LetsEncrypt/PublicAPI.Shipped.txt b/src/LetsEncrypt/PublicAPI.Shipped.txt index 92595f8b..37912363 100644 --- a/src/LetsEncrypt/PublicAPI.Shipped.txt +++ b/src/LetsEncrypt/PublicAPI.Shipped.txt @@ -22,10 +22,6 @@ McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.FallbackCertificate.set -> vo McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.KeyAlgorithm.get -> McMaster.AspNetCore.LetsEncrypt.KeyAlgorithm McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.KeyAlgorithm.set -> void McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.LetsEncryptOptions() -> void -McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.get -> System.TimeSpan? -McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.set -> void -McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.get -> System.TimeSpan? -McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.set -> void McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.UseStagingServer.get -> bool McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.UseStagingServer.set -> void Microsoft.Extensions.DependencyInjection.LetsEncryptServiceCollectionExtensions diff --git a/src/LetsEncrypt/PublicAPI.Unshipped.txt b/src/LetsEncrypt/PublicAPI.Unshipped.txt index 205bcda3..cf6d3084 100644 --- a/src/LetsEncrypt/PublicAPI.Unshipped.txt +++ b/src/LetsEncrypt/PublicAPI.Unshipped.txt @@ -9,3 +9,7 @@ McMaster.AspNetCore.LetsEncrypt.Accounts.AccountModel.PrivateKey.set -> void McMaster.AspNetCore.LetsEncrypt.Accounts.IAccountStore McMaster.AspNetCore.LetsEncrypt.Accounts.IAccountStore.SaveAccountAsync(McMaster.AspNetCore.LetsEncrypt.Accounts.AccountModel account, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task McMaster.AspNetCore.LetsEncrypt.Accounts.IAccountStore.GetAccountAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.get -> System.TimeSpan? +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewalCheckPeriod.set -> void +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.get -> System.TimeSpan? +McMaster.AspNetCore.LetsEncrypt.LetsEncryptOptions.RenewDaysInAdvance.set -> void \ No newline at end of file From b5404b34f15529bfa05efd08237b32588f6dd4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Bi=C4=8Dan?= Date: Tue, 12 May 2020 10:14:56 +0200 Subject: [PATCH 3/5] Update src/LetsEncrypt/LetsEncryptOptions.cs Co-authored-by: Nate McMaster --- src/LetsEncrypt/LetsEncryptOptions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LetsEncrypt/LetsEncryptOptions.cs b/src/LetsEncrypt/LetsEncryptOptions.cs index efec1af4..91581db2 100644 --- a/src/LetsEncrypt/LetsEncryptOptions.cs +++ b/src/LetsEncrypt/LetsEncryptOptions.cs @@ -90,7 +90,6 @@ internal Uri GetAcmeServer(IHostEnvironment env) public TimeSpan? RenewalCheckPeriod { get; set; } = TimeSpan.FromDays(1); /// - /// The uri to the server that implements the ACME protocol for certificate generation. /// Asymetric encryption algorithm: RS256, ES256, ES384, ES512 /// public KeyAlgorithm KeyAlgorithm { get; set; } = KeyAlgorithm.ES256; From 368703c9e787630d10cf0c490f2511f117437c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Bi=C4=8Dan?= Date: Tue, 12 May 2020 10:43:19 +0200 Subject: [PATCH 4/5] #11 Introduce IClock dependency --- src/LetsEncrypt/Internal/AcmeCertificateLoader.cs | 6 +++++- src/LetsEncrypt/Internal/IO/IClock.cs | 9 +++++++++ src/LetsEncrypt/Internal/IO/SystemClock.cs | 9 +++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/LetsEncrypt/Internal/IO/IClock.cs create mode 100644 src/LetsEncrypt/Internal/IO/SystemClock.cs diff --git a/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs b/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs index 1c0a506f..c3c8efc8 100644 --- a/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs +++ b/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using McMaster.AspNetCore.LetsEncrypt.Accounts; +using McMaster.AspNetCore.LetsEncrypt.Internal.IO; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; @@ -37,6 +38,7 @@ internal class AcmeCertificateLoader : BackgroundService private readonly IConfiguration _config; private readonly TermsOfServiceChecker _tosChecker; private readonly IEnumerable _certificateRepositories; + private readonly IClock _clock; private const string ErrorMessage = "Failed to create certificate"; @@ -50,6 +52,7 @@ public AcmeCertificateLoader( IConfiguration config, TermsOfServiceChecker tosChecker, IEnumerable certificateRepositories, + IClock clock, IAccountStore? accountStore = default) { _selector = selector; @@ -62,6 +65,7 @@ public AcmeCertificateLoader( _config = config; _tosChecker = tosChecker; _certificateRepositories = certificateRepositories; + _clock = clock; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -201,7 +205,7 @@ private async Task MonitorRenewal(CancellationToken cancellationToken) { if (!_selector.TryGet(domainName, out var cert) || cert == null - || cert.NotAfter <= DateTime.Now + daysInAdvance.Value) + || cert.NotAfter <= _clock.Now.DateTime + daysInAdvance.Value) { await CreateCertificateAsync(domainNames, cancellationToken); break; diff --git a/src/LetsEncrypt/Internal/IO/IClock.cs b/src/LetsEncrypt/Internal/IO/IClock.cs new file mode 100644 index 00000000..bed31d7c --- /dev/null +++ b/src/LetsEncrypt/Internal/IO/IClock.cs @@ -0,0 +1,9 @@ +using System; + +namespace McMaster.AspNetCore.LetsEncrypt.Internal.IO +{ + internal interface IClock + { + DateTimeOffset Now { get; } + } +} diff --git a/src/LetsEncrypt/Internal/IO/SystemClock.cs b/src/LetsEncrypt/Internal/IO/SystemClock.cs new file mode 100644 index 00000000..77abeba9 --- /dev/null +++ b/src/LetsEncrypt/Internal/IO/SystemClock.cs @@ -0,0 +1,9 @@ +using System; + +namespace McMaster.AspNetCore.LetsEncrypt.Internal.IO +{ + internal class SystemClock: IClock + { + public DateTimeOffset Now => DateTimeOffset.Now; + } +} From 95ac17c92c7a9e7a1411058fd6414969b9f75d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Bi=C4=8Dan?= Date: Tue, 12 May 2020 17:09:31 +0200 Subject: [PATCH 5/5] #11 Register IClock in IoC container --- src/LetsEncrypt/LetsEncryptServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/LetsEncrypt/LetsEncryptServiceCollectionExtensions.cs b/src/LetsEncrypt/LetsEncryptServiceCollectionExtensions.cs index e0bead74..64a8a7ac 100644 --- a/src/LetsEncrypt/LetsEncryptServiceCollectionExtensions.cs +++ b/src/LetsEncrypt/LetsEncryptServiceCollectionExtensions.cs @@ -44,6 +44,7 @@ public static ILetsEncryptServiceBuilder AddLetsEncrypt(this IServiceCollection services.AddSingleton() .AddSingleton(PhysicalConsole.Singleton) + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton()