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