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

feature: add certificate-per-domain functionality #301

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 5 additions & 3 deletions samples/KeyVault/appsettings.Production.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"LettuceEncrypt": {
"DomainNames": [
"example.com",
"www.example.com",
"www2.example.com"
[
"example.com",
"www.example.com",
"www2.example.com"
]
],
"AzureKeyVault": {
"AzureKeyVaultEndpoint": "https://my-production-secrets.vault.azure.net"
Expand Down
2 changes: 1 addition & 1 deletion samples/KeyVault/appsettings.Staging.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"LettuceEncrypt": {
"DomainNames": [
"staging.example.com"
[ "staging.example.com" ]
],
"AzureKeyVault": {
"AzureKeyVaultEndpoint": "https://my-staging-secrets.vault.azure.net"
Expand Down
8 changes: 5 additions & 3 deletions samples/Web/appsettings.Production.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"LettuceEncrypt": {
"DomainNames": [
"example.com",
"www.example.com",
"www2.example.com"
[
"example.com",
"www.example.com",
"www2.example.com"
]
]
}
}
2 changes: 1 addition & 1 deletion samples/Web/appsettings.Staging.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"LettuceEncrypt": {
"DomainNames": [
"staging.example.com"
[ "staging.example.com" ]
],
"UseStagingServer": true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task<IEnumerable<X509Certificate2>> GetCertificatesAsync(Cancellati
{
var certs = new List<X509Certificate2>();

foreach (var domain in _encryptOptions.Value.DomainNames)
foreach (var domain in _encryptOptions.Value.DomainNames.SelectMany(domainName => domainName))
{
var cert = await GetCertificateWithPrivateKeyAsync(domain, cancellationToken);

Expand Down
14 changes: 7 additions & 7 deletions src/LettuceEncrypt/Internal/AcmeCertificateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ private async Task<bool> ExistingAccountIsValidAsync()
return true;
}

public async Task<X509Certificate2> CreateCertificateAsync(CancellationToken cancellationToken)
public async Task<X509Certificate2> CreateCertificateAsync(string[] domainNames, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_client == null)
Expand All @@ -165,7 +165,7 @@ public async Task<X509Certificate2> CreateCertificateAsync(CancellationToken can
var orders = await _client.GetOrdersAsync();
if (orders.Any())
{
var expectedDomains = new HashSet<string>(_options.Value.DomainNames);
var expectedDomains = new HashSet<string>(domainNames);
foreach (var order in orders)
{
var orderDetails = await _client.GetOrderDetailsAsync(order);
Expand All @@ -191,7 +191,7 @@ public async Task<X509Certificate2> CreateCertificateAsync(CancellationToken can
if (orderContext == null)
{
_logger.LogDebug("Creating new order for a certificate");
orderContext = await _client.CreateOrderAsync(_options.Value.DomainNames);
orderContext = await _client.CreateOrderAsync(domainNames);
}

cancellationToken.ThrowIfCancellationRequested();
Expand All @@ -201,7 +201,7 @@ public async Task<X509Certificate2> CreateCertificateAsync(CancellationToken can
await Task.WhenAll(BeginValidateAllAuthorizations(authorizations, cancellationToken));

cancellationToken.ThrowIfCancellationRequested();
return await CompleteCertificateRequestAsync(orderContext, cancellationToken);
return await CompleteCertificateRequestAsync(orderContext, domainNames, cancellationToken);
}

private IEnumerable<Task> BeginValidateAllAuthorizations(IEnumerable<IAuthorizationContext> authorizations,
Expand Down Expand Up @@ -282,7 +282,7 @@ private async Task ValidateDomainOwnershipAsync(IAuthorizationContext authorizat
throw new InvalidOperationException($"Failed to validate ownership of domainName '{domainName}'");
}

private async Task<X509Certificate2> CompleteCertificateRequestAsync(IOrderContext order,
private async Task<X509Certificate2> CompleteCertificateRequestAsync(IOrderContext order, string[] domainNames,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Expand All @@ -291,7 +291,7 @@ private async Task<X509Certificate2> CompleteCertificateRequestAsync(IOrderConte
throw new InvalidOperationException();
}

var commonName = _options.Value.DomainNames[0];
var commonName = domainNames[0];
_logger.LogDebug("Creating cert for {commonName}", commonName);

var csrInfo = new CsrInfo
Expand All @@ -305,7 +305,7 @@ private async Task<X509Certificate2> CompleteCertificateRequestAsync(IOrderConte
_logger.LogAcmeAction("NewCertificate");

var pfxBuilder = CreatePfxBuilder(acmeCert, privateKey);
var pfx = pfxBuilder.Build("HTTPS Cert - " + _options.Value.DomainNames, string.Empty);
var pfx = pfxBuilder.Build("HTTPS Cert - " + domainNames, string.Empty);
return new X509Certificate2(pfx, string.Empty, X509KeyStorageFlags.Exportable);
}

Expand Down
6 changes: 4 additions & 2 deletions src/LettuceEncrypt/Internal/AcmeCertificateLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}

private bool LettuceEncryptDomainNamesWereConfigured()
=> _options.Value.DomainNames
.Any(w => !string.Equals("localhost", w, StringComparison.OrdinalIgnoreCase));
{
return _options.Value.DomainNames.All(domains =>
domains.Any(w => !string.Equals("localhost", w, StringComparison.OrdinalIgnoreCase)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,31 @@ namespace LettuceEncrypt.Internal.AcmeStates;

internal class BeginCertificateCreationState : AcmeState
{
private readonly ILogger<ServerStartupState> _logger;
private readonly ILogger<BeginCertificateCreationState> _logger;
private readonly IOptions<LettuceEncryptOptions> _options;
private readonly AcmeCertificateFactory _acmeCertificateFactory;
private readonly CertificateSelector _selector;
private readonly DomainNamesEnumerator _domainNamesEnumerator;
private readonly IEnumerable<ICertificateRepository> _certificateRepositories;

public BeginCertificateCreationState(
AcmeStateMachineContext context, ILogger<ServerStartupState> logger,
AcmeStateMachineContext context, ILogger<BeginCertificateCreationState> logger,
IOptions<LettuceEncryptOptions> options, AcmeCertificateFactory acmeCertificateFactory,
CertificateSelector selector, IEnumerable<ICertificateRepository> certificateRepositories)
CertificateSelector selector, DomainNamesEnumerator domainNamesEnumerator,
IEnumerable<ICertificateRepository> certificateRepositories)
: base(context)
{
_logger = logger;
_options = options;
_acmeCertificateFactory = acmeCertificateFactory;
_selector = selector;
_domainNamesEnumerator = domainNamesEnumerator;
_certificateRepositories = certificateRepositories;
}

public override async Task<IAcmeState> MoveNextAsync(CancellationToken cancellationToken)
{
var domainNames = _options.Value.DomainNames;
var domainNames = _domainNamesEnumerator.Current;

try
{
Expand All @@ -40,7 +43,7 @@ public override async Task<IAcmeState> MoveNextAsync(CancellationToken cancellat
_logger.LogInformation("Creating certificate for {hostname}",
string.Join(",", domainNames));

var cert = await _acmeCertificateFactory.CreateCertificateAsync(cancellationToken);
var cert = await _acmeCertificateFactory.CreateCertificateAsync(domainNames, cancellationToken);

_logger.LogInformation("Created certificate {subjectName} ({thumbprint})",
cert.Subject,
Expand All @@ -50,7 +53,7 @@ public override async Task<IAcmeState> MoveNextAsync(CancellationToken cancellat
}
catch (Exception ex)
{
_logger.LogError(0, ex, "Failed to automatically create a certificate for {hostname}", domainNames);
_logger.LogError(0, ex, "Failed to automatically create a certificate for {hostname}", string.Join(", ", domainNames));
throw;
}

Expand All @@ -61,17 +64,18 @@ private async Task SaveCertificateAsync(X509Certificate2 cert, CancellationToken
{
_selector.Add(cert);

var saveTasks = new List<Task>
{
Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)
};
var saveTasks = new List<Task>();

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCts.CancelAfter(TimeSpan.FromMinutes(5));
var linkedToken = linkedCts.Token;

var errors = new List<Exception>();
foreach (var repo in _certificateRepositories)
{
try
{
saveTasks.Add(repo.SaveAsync(cert, cancellationToken));
saveTasks.Add(repo.SaveAsync(cert, linkedToken));
}
catch (Exception ex)
{
Expand All @@ -80,7 +84,14 @@ private async Task SaveCertificateAsync(X509Certificate2 cert, CancellationToken
}
}

await Task.WhenAll(saveTasks);
try
{
await Task.WhenAll(saveTasks);
}
catch (TaskCanceledException e)
{
errors.Add(e);
}

if (errors.Count > 0)
{
Expand Down
53 changes: 26 additions & 27 deletions src/LettuceEncrypt/Internal/AcmeStates/CheckForRenewalState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,59 +7,58 @@

namespace LettuceEncrypt.Internal.AcmeStates;

internal class CheckForRenewalState : AcmeState
internal class CheckForRenewalState : SyncAcmeState
{
private readonly ILogger<CheckForRenewalState> _logger;
private readonly IOptions<LettuceEncryptOptions> _options;
private readonly CertificateSelector _selector;
private readonly DomainNamesEnumerator _domainNamesEnumerator;
private readonly IClock _clock;

public CheckForRenewalState(
AcmeStateMachineContext context,
ILogger<CheckForRenewalState> logger,
IOptions<LettuceEncryptOptions> options,
CertificateSelector selector,
DomainNamesEnumerator domainNamesEnumerator,
IClock clock) : base(context)
{
_logger = logger;
_options = options;
_selector = selector;
_domainNamesEnumerator = domainNamesEnumerator;
_clock = clock;
}

public override async Task<IAcmeState> MoveNextAsync(CancellationToken cancellationToken)
public override IAcmeState MoveNext()
{
while (!cancellationToken.IsCancellationRequested)
var checkPeriod = _options.Value.RenewalCheckPeriod;
var daysInAdvance = _options.Value.RenewDaysInAdvance;
if (!checkPeriod.HasValue || !daysInAdvance.HasValue)
{
var checkPeriod = _options.Value.RenewalCheckPeriod;
var daysInAdvance = _options.Value.RenewDaysInAdvance;
if (!checkPeriod.HasValue || !daysInAdvance.HasValue)
{
_logger.LogInformation("Automatic certificate renewal is not configured. Stopping {service}",
nameof(AcmeCertificateLoader));
return MoveTo<TerminalState>();
}
_logger.LogInformation("Automatic certificate renewal is not configured. Stopping {service}",
nameof(AcmeCertificateLoader));
return MoveTo<TerminalState>();
}

var domainNames = _options.Value.DomainNames;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Checking certificates' renewals for {hostname}",
string.Join(", ", domainNames));
}
var domainNames = _domainNamesEnumerator.Current;

foreach (var domainName in domainNames)
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Checking certificates' renewals for {hostname}",
string.Join(", ", domainNames));
}

foreach (var domainName in domainNames)
{
if (!_selector.TryGet(domainName, out var cert)
|| cert == null
|| cert.NotAfter <= _clock.Now.DateTime + daysInAdvance.Value)
{
if (!_selector.TryGet(domainName, out var cert)
|| cert == null
|| cert.NotAfter <= _clock.Now.DateTime + daysInAdvance.Value)
{
return MoveTo<BeginCertificateCreationState>();
}
return MoveTo<BeginCertificateCreationState>();
}

await Task.Delay(checkPeriod.Value, cancellationToken);
}

return MoveTo<TerminalState>();
return MoveTo<ServerStartupState>();
}
}
21 changes: 15 additions & 6 deletions src/LettuceEncrypt/Internal/AcmeStates/ServerStartupState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,39 @@ internal class ServerStartupState : SyncAcmeState
{
private readonly IOptions<LettuceEncryptOptions> _options;
private readonly CertificateSelector _selector;
private readonly DomainNamesEnumerator _domainNamesEnumerator;
private readonly ILogger<ServerStartupState> _logger;

public ServerStartupState(
AcmeStateMachineContext context,
IOptions<LettuceEncryptOptions> options,
CertificateSelector selector,
DomainNamesEnumerator domainNamesEnumerator,
ILogger<ServerStartupState> logger) :
base(context)
{
_options = options;
_selector = selector;
_domainNamesEnumerator = domainNamesEnumerator;
_logger = logger;
}

public override IAcmeState MoveNext()
{
var domainNames = _options.Value.DomainNames;
var hasCertForAllDomains = domainNames.All(_selector.HasCertForDomain);
if (hasCertForAllDomains)
while (_domainNamesEnumerator.MoveNext())
{
_logger.LogDebug("Certificate for {domainNames} already found.", domainNames);
return MoveTo<CheckForRenewalState>();
var domainNames = _domainNamesEnumerator.Current;
var hasCertForAllDomains = domainNames.All(_selector.HasCertForDomain);
if (hasCertForAllDomains)
{
_logger.LogDebug("Certificate for [{domainNames}] already found.", string.Join(", ", domainNames));
return MoveTo<CheckForRenewalState>();
}

return MoveTo<BeginCertificateCreationState>();
}

return MoveTo<BeginCertificateCreationState>();
_domainNamesEnumerator.Reset();
return MoveTo<WaitState>();
}
}
Loading