diff --git a/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs b/src/LetsEncrypt/Internal/AcmeCertificateLoader.cs index e24d848b..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; @@ -24,7 +25,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 +38,9 @@ internal class AcmeCertificateLoader : IHostedService private readonly IConfiguration _config; private readonly TermsOfServiceChecker _tosChecker; private readonly IEnumerable _certificateRepositories; - private volatile bool _hasRegistered; + private readonly IClock _clock; + + private const string ErrorMessage = "Failed to create certificate"; public AcmeCertificateLoader( CertificateSelector selector, @@ -49,6 +52,7 @@ public AcmeCertificateLoader( IConfiguration config, TermsOfServiceChecker tosChecker, IEnumerable certificateRepositories, + IClock clock, IAccountStore? accountStore = default) { _selector = selector; @@ -61,25 +65,23 @@ public AcmeCertificateLoader( _config = config; _tosChecker = tosChecker; _certificateRepositories = certificateRepositories; + _clock = clock; } - 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 +89,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 +106,9 @@ public Task StartAsync(CancellationToken cancellationToken) { _logger.LogError(0, ex, ErrorMessage); } - }); - return Task.CompletedTask; + await MonitorRenewal(stoppingToken); + }); } private bool LetsEncryptDomainNamesWereConfigured() @@ -130,6 +130,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 +143,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 +152,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 +181,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 <= _clock.Now.DateTime + 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/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/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; + } +} diff --git a/src/LetsEncrypt/LetsEncryptOptions.cs b/src/LetsEncrypt/LetsEncryptOptions.cs index 59113690..91581db2 100644 --- a/src/LetsEncrypt/LetsEncryptOptions.cs +++ b/src/LetsEncrypt/LetsEncryptOptions.cs @@ -79,6 +79,16 @@ 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); + /// /// Asymetric encryption algorithm: RS256, ES256, ES384, ES512 /// 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() 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