Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#11 auto renew certificates #73

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 61 additions & 23 deletions src/LetsEncrypt/Internal/AcmeCertificateLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace McMaster.AspNetCore.LetsEncrypt.Internal
/// <summary>
/// Loads certificates for all configured hostnames
/// </summary>
internal class AcmeCertificateLoader : IHostedService
internal class AcmeCertificateLoader : BackgroundService
{
private readonly CertificateSelector _selector;
private readonly IHttpChallengeResponseStore _challengeStore;
Expand All @@ -37,7 +37,8 @@ internal class AcmeCertificateLoader : IHostedService
private readonly IConfiguration _config;
private readonly TermsOfServiceChecker _tosChecker;
private readonly IEnumerable<ICertificateRepository> _certificateRepositories;
private volatile bool _hasRegistered;

private const string ErrorMessage = "Failed to create certificate";

public AcmeCertificateLoader(
CertificateSelector selector,
Expand All @@ -63,40 +64,35 @@ 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<bool>("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

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)
{
Expand All @@ -106,9 +102,9 @@ public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogError(0, ex, ErrorMessage);
}
});

return Task.CompletedTask;
await MonitorRenewal(stoppingToken);
});
}

private bool LetsEncryptDomainNamesWereConfigured()
Expand All @@ -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,
Expand All @@ -138,21 +139,15 @@ 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
{
_logger.LogInformation("Creating certificate for {hostname} using ACME server {acmeServer}",
domainNames,
factory.AcmeServer);


var cert = await factory.CreateCertificateAsync(cancellationToken);

_logger.LogInformation("Created certificate {subjectName} ({thumbprint})", cert.Subject, cert.Thumbprint);
Expand Down Expand Up @@ -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);
}
}
}
}
5 changes: 5 additions & 0 deletions src/LetsEncrypt/Internal/CertificateSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
11 changes: 11 additions & 0 deletions src/LetsEncrypt/LetsEncryptOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ internal Uri GetAcmeServer(IHostEnvironment env)
public X509Certificate2? FallbackCertificate { get; set; }

/// <summary>
/// How long before certificate expiration will be renewal attempted
/// </summary>
public TimeSpan? RenewDaysInAdvance { get; set; } = TimeSpan.FromDays(30);

/// <summary>
/// How often will be certificates checked for renewal
/// </summary>
public TimeSpan? RenewalCheckPeriod { get; set; } = TimeSpan.FromDays(1);

/// <summary>
/// The uri to the server that implements the ACME protocol for certificate generation.
mbican marked this conversation as resolved.
Show resolved Hide resolved
/// Asymetric encryption algorithm: RS256, ES256, ES384, ES512
/// </summary>
public KeyAlgorithm KeyAlgorithm { get; set; } = KeyAlgorithm.ES256;
Expand Down
4 changes: 4 additions & 0 deletions src/LetsEncrypt/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.Accounts.AccountModel>
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