diff options
author | Wouter Tinus <wouter.tinus@gmail.com> | 2020-06-14 19:50:41 +0200 |
---|---|---|
committer | Wouter Tinus <wouter.tinus@gmail.com> | 2020-06-14 19:50:41 +0200 |
commit | e49b52549f6f573d708a882ee70eaebbc0bd9dd5 (patch) | |
tree | 042606e067d7bdb37bce7e370d54865ef74d8f17 /src | |
parent | 0c1e53dca87941a6ca20e72772ef36c094e3abff (diff) | |
download | letsencrypt-win-simple-e49b52549f6f573d708a882ee70eaebbc0bd9dd5.zip letsencrypt-win-simple-e49b52549f6f573d708a882ee70eaebbc0bd9dd5.tar.gz letsencrypt-win-simple-e49b52549f6f573d708a882ee70eaebbc0bd9dd5.tar.bz2 |
enable parallel validation for http-01 selfhosting
Diffstat (limited to 'src')
23 files changed, 614 insertions, 361 deletions
diff --git a/src/main.lib/Clients/Acme/AcmeClient.cs b/src/main.lib/Clients/Acme/AcmeClient.cs index 2783881..5d8fed9 100644 --- a/src/main.lib/Clients/Acme/AcmeClient.cs +++ b/src/main.lib/Clients/Acme/AcmeClient.cs @@ -16,6 +16,7 @@ using System.Net.Http; using System.Net.Mail;
using System.Security.Authentication;
using System.Security.Cryptography;
+using System.Threading;
using System.Threading.Tasks;
namespace PKISharp.WACS.Clients.Acme
@@ -529,6 +530,7 @@ namespace PKISharp.WACS.Clients.Acme /// <returns></returns>
private async Task<T> Retry<T>(Func<Task<T>> executor, int attempt = 0)
{
+ await _requestLock.WaitAsync();
try
{
return await executor();
@@ -547,8 +549,18 @@ namespace PKISharp.WACS.Clients.Acme throw;
}
}
+ finally
+ {
+ _requestLock.Release();
+ }
}
+ /// <summary>
+ /// Prevent sending simulateous requests to the ACME service because it messes
+ /// up the nonce tracking mechanism
+ /// </summary>
+ private readonly SemaphoreSlim _requestLock = new SemaphoreSlim(1, 1);
+
#region IDisposable Support
private bool disposedValue = false; // To detect redundant calls
diff --git a/src/main.lib/Context/ExecutionContext.cs b/src/main.lib/Context/ExecutionContext.cs new file mode 100644 index 0000000..23d3373 --- /dev/null +++ b/src/main.lib/Context/ExecutionContext.cs @@ -0,0 +1,26 @@ +using Autofac; +using PKISharp.WACS.DomainObjects; + +namespace PKISharp.WACS.Context +{ + /// <summary> + /// Common objects used throughout the renewal process + /// </summary> + internal class ExecutionContext + { + public ILifetimeScope Scope { get; private set; } + public Order Order { get; private set; } + public RunLevel RunLevel { get; private set; } + public RenewResult Result { get; private set; } + public Target Target => Order.Target; + public Renewal Renewal => Order.Renewal; + + public ExecutionContext(ILifetimeScope scope, Order order, RunLevel runLevel, RenewResult result) + { + Scope = scope; + Order = order; + RunLevel = runLevel; + Result = result; + } + } +} diff --git a/src/main.lib/Context/ValidationContext.cs b/src/main.lib/Context/ValidationContext.cs new file mode 100644 index 0000000..c5c9544 --- /dev/null +++ b/src/main.lib/Context/ValidationContext.cs @@ -0,0 +1,71 @@ +using ACMESharp.Authorizations; +using ACMESharp.Protocol.Resources; +using Autofac; +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Interfaces; +using System.Collections.Generic; + +namespace PKISharp.WACS.Context +{ + public class ValidationContextParameters + { + public ValidationContextParameters( + Authorization authorization, + TargetPart? targetPart, + string challengeType, + string pluginName) + { + TargetPart = targetPart; + Authorization = authorization; + ChallengeType = challengeType; + PluginName = pluginName; + } + + public string ChallengeType { get; } + public string PluginName { get; } + public TargetPart? TargetPart { get; } + public Authorization Authorization { get; } + } + + public class ValidationContext + { + public ValidationContext( + ILifetimeScope scope, + ValidationContextParameters parameters) + { + Identifier = parameters.Authorization.Identifier.Value; + TargetPart = parameters.TargetPart; + Authorization = parameters.Authorization; + Scope = scope; + ChallengeType = parameters.ChallengeType; + PluginName = parameters.PluginName; + ValidationPlugin = scope.Resolve<IValidationPlugin>(); + } + public ILifetimeScope Scope { get; } + public string Identifier { get; } + public string ChallengeType { get; } + public string PluginName { get; } + public TargetPart? TargetPart { get; } + public Authorization Authorization { get; } + public Challenge? Challenge { get; set; } + public IChallengeValidationDetails? ChallengeDetails { get; set; } + public IValidationPlugin ValidationPlugin { get; set; } + public bool? Success { get; set; } + public List<string> ErrorMessages { get; } = new List<string>(); + public void AddErrorMessage(string? value, bool fatal = true) + { + if (value != null) + { + if (!ErrorMessages.Contains(value)) + { + ErrorMessages.Add(value); + } + } + if (fatal) + { + Success = false; + } + } + } + +} diff --git a/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs b/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs index 8b6820d..fb3a8e9 100644 --- a/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs +++ b/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs @@ -1,8 +1,5 @@ -using ACMESharp.Authorizations; -using ACMESharp.Protocol.Resources; -using Autofac; -using PKISharp.WACS.DomainObjects; -using System.Collections.Generic; +using PKISharp.WACS.Context; +using System; using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.Interfaces @@ -24,50 +21,20 @@ namespace PKISharp.WACS.Plugins.Interfaces /// <summary> /// Clean up after validation attempt /// </summary> - Task CleanUp(ValidationContext context); + Task CleanUp(ValidationContext context); + + /// <summary> + /// Indicate level of supported parallelism + /// </summary> + ParallelOperations Parallelism { get; } } - public class ValidationContext + [Flags] + public enum ParallelOperations { - public ValidationContext( - ILifetimeScope scope, - Authorization authorization, - TargetPart targetPart, - string challengeType, - string pluginName) - { - Identifier = authorization.Identifier.Value; - TargetPart = targetPart; - Authorization = authorization; - Scope = scope; - ChallengeType = challengeType; - PluginName = pluginName; - } - public ILifetimeScope Scope { get; } - public string Identifier { get; } - public string ChallengeType { get; } - public string PluginName { get; } - public TargetPart TargetPart { get; } - public Authorization Authorization { get; } - public Challenge? Challenge { get; set; } - public IChallengeValidationDetails? ChallengeDetails { get; set; } - public IValidationPlugin? ValidationPlugin { get; set; } - public bool? Success { get; set; } - public List<string> ErrorMessages { get; } = new List<string>(); - public void AddErrorMessage(string? value, bool fatal = true) - { - if (value != null) - { - if (!ErrorMessages.Contains(value)) - { - ErrorMessages.Add(value); - } - } - if (fatal) - { - Success = false; - } - } + None = 0, + Prepare = 1, + Answer = 2, + Clean = 4 } - } diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs index b24cc38..ab167b1 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs @@ -1,6 +1,6 @@ using PKISharp.WACS.Clients; using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Threading.Tasks; diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs index d88cc47..d22f112 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs @@ -1,7 +1,6 @@ using ACMESharp.Authorizations;
using PKISharp.WACS.Clients.DNS;
-using PKISharp.WACS.DomainObjects;
-using PKISharp.WACS.Plugins.Interfaces;
+using PKISharp.WACS.Context;
using PKISharp.WACS.Services;
using System;
using System.Collections.Generic;
diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs index b65ea07..077833d 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs @@ -1,5 +1,5 @@ using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System.Threading.Tasks; diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs index 0f2c6f4..ad58904 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs @@ -1,6 +1,6 @@ using PKISharp.WACS.Clients; using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System.Threading.Tasks; diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs index 844b92e..01312a2 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs @@ -1,4 +1,5 @@ using ACMESharp.Authorizations; +using PKISharp.WACS.Context; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; @@ -79,7 +80,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// </summary> public async override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) { - Refresh(context.TargetPart); + // Should always have a value, confirmed by RenewalExecutor + // check only to satifiy the compiler + if (context.TargetPart != null) + { + Refresh(context.TargetPart); + } WriteAuthorizationFile(challenge); WriteWebConfig(challenge); _log.Information("Answer should now be browsable at {answerUri}", challenge.HttpResourceUrl); diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs index ad3d3be..c291e59 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs @@ -1,7 +1,10 @@ using ACMESharp.Authorizations; +using DnsClient; +using PKISharp.WACS.Context; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; @@ -14,12 +17,19 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http internal const int DefaultHttpValidationPort = 80; internal const int DefaultHttpsValidationPort = 443; + private readonly object _listenerLock = new object(); private HttpListener? _listener; - private readonly Dictionary<string, string> _files; + private readonly ConcurrentDictionary<string, string> _files; private readonly SelfHostingOptions _options; private readonly ILogService _log; private readonly IUserRoleService _userRoleService; + /// <summary> + /// We can answer requests for multiple domains + /// </summary> + public override ParallelOperations Parallelism => + ParallelOperations.Answer | ParallelOperations.Clean | ParallelOperations.Prepare; + private bool HasListener => _listener != null; private HttpListener Listener { @@ -38,11 +48,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http { _log = log; _options = options; - _files = new Dictionary<string, string>(); + _files = new ConcurrentDictionary<string, string>(); _userRoleService = userRoleService; } - public async Task ReceiveRequests() + private async Task ReceiveRequests() { while (Listener.IsListening) { @@ -64,40 +74,55 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http public override Task CleanUp(ValidationContext context, Http01ChallengeValidationDetails challenge) { - if (HasListener) + // Cleanup listener if nobody else has done it yet + lock (_listenerLock) { - try - { - Listener.Stop(); - Listener.Close(); - } - catch + if (HasListener) { + try + { + Listener.Stop(); + Listener.Close(); + } + finally + { + _listener = null; + } } } + return Task.CompletedTask; } public override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) { - _files.Add("/" + challenge.HttpResourcePath, challenge.HttpResourceValue); - var protocol = _options.Https == true ? "https" : "http"; - var port = _options.Port ?? (_options.Https == true ? - DefaultHttpsValidationPort : - DefaultHttpValidationPort); - var prefix = $"{protocol}://+:{port}/.well-known/acme-challenge/"; - try - { - Listener = new HttpListener(); - Listener.Prefixes.Add(prefix); - Listener.Start(); - Task.Run(ReceiveRequests); - } - catch + // Create listener if it doesn't exist yet + lock (_listenerLock) { - _log.Error("Unable to activate listener, this may be because of insufficient rights or a non-Microsoft webserver using port {port}", port); - throw; + if (_listener == null) + { + var protocol = _options.Https == true ? "https" : "http"; + var port = _options.Port ?? (_options.Https == true ? + DefaultHttpsValidationPort : + DefaultHttpValidationPort); + var prefix = $"{protocol}://+:{port}/.well-known/acme-challenge/"; + try + { + Listener = new HttpListener(); + Listener.Prefixes.Add(prefix); + Listener.Start(); + Task.Run(ReceiveRequests); + } + catch + { + _log.Error("Unable to activate listener, this may be because of insufficient rights or a non-Microsoft webserver using port {port}", port); + throw; + } + } } + + // Add validation file + _files.GetOrAdd("/" + challenge.HttpResourcePath, challenge.HttpResourceValue); return Task.CompletedTask; } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs index 9afd9d1..9f3d138 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs @@ -1,6 +1,6 @@ using ACMESharp.Authorizations; using PKISharp.WACS.Client; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System.Threading.Tasks; diff --git a/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs index baefd16..4e6c1e2 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs @@ -1,5 +1,6 @@ using ACMESharp.Authorizations; using Org.BouncyCastle.Asn1; +using PKISharp.WACS.Context; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; diff --git a/src/main.lib/Plugins/ValidationPlugins/Validation.cs b/src/main.lib/Plugins/ValidationPlugins/Validation.cs index b97cea6..4205aa2 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Validation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Validation.cs @@ -1,4 +1,5 @@ using ACMESharp.Authorizations; +using PKISharp.WACS.Context; using PKISharp.WACS.Plugins.Interfaces; using System; using System.Threading.Tasks; @@ -53,5 +54,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// Is the plugin currently disabled /// </summary> public virtual (bool, string?) Disabled => (false, null); + + /// <summary> + /// No parallelism by default + /// </summary> + public virtual ParallelOperations Parallelism => ParallelOperations.None; } } diff --git a/src/main.lib/RenewalExecutor.cs b/src/main.lib/RenewalExecutor.cs index 9af69cd..fb70a28 100644 --- a/src/main.lib/RenewalExecutor.cs +++ b/src/main.lib/RenewalExecutor.cs @@ -1,6 +1,8 @@ using Autofac; +using Newtonsoft.Json.Schema; using PKISharp.WACS.Clients.Acme; using PKISharp.WACS.Configuration; +using PKISharp.WACS.Context; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Extensions; using PKISharp.WACS.Plugins.Base.Options; @@ -24,33 +26,15 @@ namespace PKISharp.WACS private readonly ILogService _log; private readonly IInputService _input; private readonly ExceptionHandler _exceptionHandler; - - /// <summary> - /// Common objects used throughout the renewal process - /// </summary> - private class ExecutionContext - { - public ILifetimeScope Scope { get; private set; } - public Order Order { get; private set; } - public RunLevel RunLevel { get; private set; } - public RenewResult Result { get; private set; } - public Target Target => Order.Target; - public Renewal Renewal => Order.Renewal; - - public ExecutionContext(ILifetimeScope scope, Order order, RunLevel runLevel, RenewResult result) - { - Scope = scope; - Order = order; - RunLevel = runLevel; - Result = result; - } - } + private readonly RenewalValidator _validator; public RenewalExecutor( MainArguments args, IAutofacBuilder scopeBuilder, ILogService log, IInputService input, + RenewalValidator validator, ExceptionHandler exceptionHandler, IContainer container) { + _validator = validator; _args = args; _scopeBuilder = scopeBuilder; _log = log; @@ -188,7 +172,7 @@ namespace PKISharp.WACS var context = new ExecutionContext(execute, order, runLevel, result); // Authorize the order (validation) - await AuthorizeOrder(context); + await _validator.AuthorizeOrder(context); if (context.Result.Success) { // Execute final steps (CSR, store, install) @@ -199,79 +183,6 @@ namespace PKISharp.WACS } /// <summary> - /// Answer all the challenges in the order - /// </summary> - /// <param name="execute"></param> - /// <param name="order"></param> - /// <param name="result"></param> - /// <param name="runLevel"></param> - /// <returns></returns> - private async Task AuthorizeOrder(ExecutionContext context) - { - // Sanity check - if (context.Order.Details == null) - { - context.Result.AddErrorMessage($"Unable to create order"); - return; - } - - // Answer the challenges - var client = context.Scope.Resolve<AcmeClient>(); - var authorizations = context.Order.Details.Payload.Authorizations.ToList(); - foreach (var authorizationUri in authorizations) - { - _log.Verbose("Handle authorization {n}/{m}", - authorizations.IndexOf(authorizationUri) + 1, - authorizations.Count); - - // Get authorization challenge details from server - var authorization = await client.GetAuthorizationDetails(authorizationUri); - - // Find a targetPart that matches the challenge - var targetPart = context.Target.Parts. - FirstOrDefault(tp => tp.GetHosts(false). - Any(h => authorization.Identifier.Value == h.Replace("*.", ""))); - if (targetPart == null) - { - context.Result.AddErrorMessage("Unable to match challenge to target"); - return; - } - - // Run the validation plugin - var options = context.Renewal.ValidationPluginOptions; - using var validation = _scopeBuilder.Validation(context.Scope, options); - var validationContext = new ValidationContext(validation, authorization, targetPart, options.ChallengeType, options.Name); - // Prepare answer - await PrepareChallengeAnswer(validationContext, context.RunLevel); - if (context.Result.Success) - { - // Submit for validation - await AnswerChallenge(validationContext); - TransferErrors(validationContext, context.Result, authorization.Identifier.Value); - } - if (validationContext.Challenge != null) - { - // Cleanup - await CleanValidation(validationContext); - TransferErrors(validationContext, context.Result, authorization.Identifier.Value); - } - if (!context.Result.Success) - { - break; - } - } - } - - /// <summary> - /// Move errors from a validation context up to the renewal result - /// </summary> - /// <param name="from"></param> - /// <param name="to"></param> - /// <param name="prefix"></param> - private void TransferErrors(ValidationContext from, RenewResult to, string prefix) => - from.ErrorMessages.ForEach(e => to.AddErrorMessage($"[{prefix}] {e}", from.Success == false)); - - /// <summary> /// Steps to take on succesful (re)authorization /// </summary> /// <param name="partialTarget"></param> @@ -419,184 +330,6 @@ namespace PKISharp.WACS context.Result.AddErrorMessage(message); } } - - /// <summary> - /// Make sure we have authorization for every host in target - /// </summary> - /// <param name="target"></param> - /// <returns></returns> - private async Task PrepareChallengeAnswer(ValidationContext context, RunLevel runLevel) - { - var client = context.Scope.Resolve<AcmeClient>(); - try - { - if (context.Authorization.Status == AcmeClient.AuthorizationValid) - { - _log.Information("[{identifier}] Cached authorization result: {Status}", context.Identifier, context.Authorization.Status); - if (!runLevel.HasFlag(RunLevel.Test) && !runLevel.HasFlag(RunLevel.IgnoreCache)) - { - return; - } - // Used to make --force or --test re-validation errors non-fatal - _log.Information("[{identifier}] Handling challenge anyway because --test and/or --force is active"); - context.Success = true; - } - - _log.Information("[{identifier}] Authorizing...", context.Identifier); - _log.Verbose("[{identifier}] Initial authorization status: {status}", context.Identifier, context.Authorization.Status); - _log.Verbose("[{identifier}] Challenge types available: {challenges}", context.Identifier, context.Authorization.Challenges.Select(x => x.Type ?? "[Unknown]")); - var challenge = context.Authorization.Challenges.FirstOrDefault(c => string.Equals(c.Type, context.ChallengeType, StringComparison.CurrentCultureIgnoreCase)); - if (challenge == null) - { - if (context.Success == true) - { - var usedType = context.Authorization.Challenges. - Where(x => x.Status == AcmeClient.ChallengeValid). - FirstOrDefault(); - _log.Warning("[{identifier}] Expected challenge type {type} not available, already validated using {valided}.", - context.Identifier, - context.ChallengeType, - usedType?.Type ?? "[unknown]"); - return; - } - else - { - _log.Error("[{identifier}] Expected challenge type {type} not available.", - context.Identifier, - context.ChallengeType); - context.AddErrorMessage("Expected challenge type not available", context.Success == false); - return; - } - } - else - { - _log.Verbose("[{identifier}] Initial challenge status: {status}", context.Identifier, challenge.Status); - if (challenge.Status == AcmeClient.ChallengeValid) - { - // We actually should not get here because if one of the - // challenges is valid, the authorization itself should also - // be valid. - if (!runLevel.HasFlag(RunLevel.Test) && !runLevel.HasFlag(RunLevel.IgnoreCache)) - { - _log.Information("[{identifier}] Cached challenge result: {Status}", context.Identifier, context.Authorization.Status); - return; - } - } - } - - // We actually have to do validation now - try - { - context.ValidationPlugin = context.Scope.Resolve<IValidationPlugin>(); - } - catch (Exception ex) - { - _log.Error(ex, "[{identifier}] Error resolving validation plugin", context.Identifier); - } - if (context.ValidationPlugin == null) - { - _log.Error("[{identifier}] Validation plugin not found or not created", context.Identifier); - context.AddErrorMessage("Validation plugin not found or not created", context.Success == false); - return; - } - var (disabled, disabledReason) = context.ValidationPlugin.Disabled; - if (disabled) - { - _log.Error($"[{{identifier}}] Validation plugin is not available. {disabledReason}", context.Identifier); - context.AddErrorMessage("Validation plugin is not available", context.Success == false); - return; - } - _log.Information("[{identifier}] Authorizing using {challengeType} validation ({name})", - context.Identifier, - context.ChallengeType, - context.PluginName); - try - { - // Now that we're going to call into PrepareChallenge, we will assume - // responsibility to also call CleanUp later, which is signalled by - // the Challenge propery being not null - context.ChallengeDetails = await client.DecodeChallengeValidation(context.Authorization, challenge); - context.Challenge = challenge; - await context.ValidationPlugin.PrepareChallenge(context); - } - catch (Exception ex) - { - _log.Error(ex, "[{identifier}] Error preparing for challenge answer", context.Identifier); - context.AddErrorMessage("Error preparing for challenge answer", context.Success == false); - return; - } - } - catch (Exception ex) - { - _log.Error("[{identifier}] Error preparing challenge answer", context.Identifier); - var message = _exceptionHandler.HandleException(ex); - context.AddErrorMessage(message, context.Success == false); - } - } - - /// <summary> - /// Make sure we have authorization for every host in target - /// </summary> - /// <param name="target"></param> - /// <returns></returns> - private async Task AnswerChallenge(ValidationContext validationContext) - { - if (validationContext.Challenge == null) - { - throw new InvalidOperationException(); - } - try - { - _log.Debug("[{identifier}] Submitting challenge answer", validationContext.Identifier); - var client = validationContext.Scope.Resolve<AcmeClient>(); - var updatedChallenge = await client.AnswerChallenge(validationContext.Challenge); - validationContext.Challenge = updatedChallenge; - if (updatedChallenge.Status != AcmeClient.ChallengeValid) - { - if (updatedChallenge.Error != null) - { - _log.Error(updatedChallenge.Error.ToString()); - } - _log.Error("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); - validationContext.AddErrorMessage(updatedChallenge.Error?.ToString() ?? "Unspecified error", validationContext.Success == false); - return; - } - else - { - _log.Information("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); - return; - } - } - catch (Exception ex) - { - _log.Error("[{identifier}] Error submitting challenge answer", validationContext.Identifier); - var message = _exceptionHandler.HandleException(ex); - validationContext.AddErrorMessage(message, validationContext.Success == false); - } - } - - /// <summary> - /// Clean up after (succesful or unsuccesful) validation attempt - /// </summary> - /// <param name="validationContext"></param> - /// <returns></returns> - private async Task CleanValidation(ValidationContext validationContext) - { - if (validationContext.Challenge == null || - validationContext.ValidationPlugin == null) - { - throw new InvalidOperationException(); - } - try - { - _log.Verbose("[{identifier}] Starting post-validation cleanup", validationContext.Identifier); - await validationContext.ValidationPlugin.CleanUp(validationContext); - _log.Verbose("[{identifier}] Post-validation cleanup was succesful", validationContext.Identifier); - } - catch (Exception ex) - { - _log.Warning("[{identifier}] An error occured during post-validation cleanup: {ex}", ex.Message, validationContext.Identifier); - } - } + } } diff --git a/src/main.lib/RenewalValidator.cs b/src/main.lib/RenewalValidator.cs new file mode 100644 index 0000000..a064984 --- /dev/null +++ b/src/main.lib/RenewalValidator.cs @@ -0,0 +1,400 @@ +using Autofac; +using PKISharp.WACS.Clients.Acme; +using PKISharp.WACS.Context; +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Extensions; +using PKISharp.WACS.Plugins.Base.Options; +using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace PKISharp.WACS +{ + /// <summary> + /// This part of the code handles the actual creation/renewal of ACME certificates + /// </summary> + internal class RenewalValidator + { + private readonly IAutofacBuilder _scopeBuilder; + private readonly ILogService _log; + private readonly ISettingsService _settings; + private readonly ExceptionHandler _exceptionHandler; + public RenewalValidator(IAutofacBuilder scopeBuilder, ISettingsService settings, ILogService log, ExceptionHandler exceptionHandler) + { + _scopeBuilder = scopeBuilder; + _log = log; + _exceptionHandler = exceptionHandler; + _settings = settings; + } + + /// <summary> + /// Answer all the challenges in the order + /// </summary> + /// <param name="execute"></param> + /// <param name="order"></param> + /// <param name="result"></param> + /// <param name="runLevel"></param> + /// <returns></returns> + public async Task AuthorizeOrder(ExecutionContext context) + { + // Sanity check + if (context.Order.Details == null) + { + context.Result.AddErrorMessage($"Unable to create order"); + return; + } + + // Get validation plugin + var options = context.Renewal.ValidationPluginOptions; + var validationScope = _scopeBuilder.Validation(context.Scope, options); + var validationPlugin = validationScope.Resolve<IValidationPlugin>(); + if (validationPlugin == null) + { + _log.Error("Validation plugin not found or not created"); + context.Result.AddErrorMessage("Validation plugin not found or not created"); + return; + } + var (disabled, disabledReason) = validationPlugin.Disabled; + if (disabled) + { + _log.Error($"Validation plugin is not available. {disabledReason}"); + context.Result.AddErrorMessage("Validation plugin is not available"); + return; + } + + // Get authorization details + var authorizations = context.Order.Details.Payload.Authorizations.ToList(); + var contextParamTasks = authorizations.Select(authorizationUri => GetValidationContextParameters(context, authorizationUri, options)); + var contextParams = (await Task.WhenAll(contextParamTasks)).ToList(); + var missingTarget = contextParams.FirstOrDefault(x => x.TargetPart == null); + if (missingTarget != null) + { + context.Result.AddErrorMessage($"Unable to match challenge {missingTarget.Authorization.Identifier.Value} to target"); + return; + } + + if (_settings.Validation.DisableMultiThreading == true || + validationPlugin.Parallelism == ParallelOperations.None) + { + await SerialValidation(context, contextParams); + } + else + { + await ParallelValidation(validationPlugin.Parallelism, validationScope, context, contextParams); + } + } + + /// <summary> + /// Handle multiple validations in parallel + /// </summary> + /// <returns></returns> + private async Task ParallelValidation(ParallelOperations level, ILifetimeScope scope, ExecutionContext context, List<ValidationContextParameters> parameters) + { + var contexts = parameters.Select(parameter => new ValidationContext(scope, parameter)).ToList(); + + // Prepare for challenge answer + if (level.HasFlag(ParallelOperations.Prepare)) + { + // Parallel + _log.Verbose("Handle {n} preparation(s)", contexts.Count); + var prepareTasks = contexts.Select(vc => PrepareChallengeAnswer(vc, context.RunLevel)); + await Task.WhenAll(prepareTasks); + foreach (var ctx in contexts) + { + TransferErrors(ctx, context.Result); + } + if (!context.Result.Success) + { + return; + } + } + else + { + // Serial + foreach (var ctx in contexts) + { + await PrepareChallengeAnswer(ctx, context.RunLevel); + TransferErrors(ctx, context.Result); + if (!context.Result.Success) + { + return; + } + } + } + + // Submit challenge answer + if (level.HasFlag(ParallelOperations.Answer)) + { + // Parallel + _log.Verbose("Handle {n} answers(s)", contexts.Count); + var answerTasks = contexts.Select(vc => AnswerChallenge(vc)); + await Task.WhenAll(answerTasks); + foreach (var ctx in contexts) + { + TransferErrors(ctx, context.Result); + } + if (!context.Result.Success) + { + return; + } + } + else + { + // Serial + foreach (var ctx in contexts) + { + await AnswerChallenge(ctx); + TransferErrors(ctx, context.Result); + if (!context.Result.Success) + { + return; + } + } + } + + if (level.HasFlag(ParallelOperations.Clean)) + { + // Parallel + _log.Verbose("Handle {n} cleanups(s)", contexts.Count); + var cleanUpTasks = contexts.Select(vc => CleanValidation(vc)); + await Task.WhenAll(cleanUpTasks); + foreach (var ctx in contexts) + { + TransferErrors(ctx, context.Result); + } + } + else + { + // Serial + foreach (var ctx in contexts) + { + if (ctx.Challenge != null) + { + // Cleanup + await CleanValidation(ctx); + TransferErrors(ctx, context.Result); + } + } + } + } + + /// <summary> + /// Handle validation in serial order + /// </summary> + /// <param name="context"></param> + /// <param name="parameters"></param> + /// <returns></returns> + private async Task SerialValidation(ExecutionContext context, List<ValidationContextParameters> parameters) + { + foreach (var parameter in parameters) + { + _log.Verbose("Handle authorization {n}/{m}", + parameters.IndexOf(parameter) + 1, + parameters.Count); + using var identifierScope = _scopeBuilder.Validation(context.Scope, context.Renewal.ValidationPluginOptions); + await ParallelValidation(ParallelOperations.None, identifierScope, context, new List<ValidationContextParameters> { parameter }); + if (!context.Result.Success) + { + break; + } + } + } + + /// <summary> + /// Get information needed to construct a validation context (shared between serial and parallel mode) + /// </summary> + /// <param name="context"></param> + /// <param name="authorizationUri"></param> + /// <param name="options"></param> + /// <returns></returns> + private async Task<ValidationContextParameters> GetValidationContextParameters(ExecutionContext context, string authorizationUri, ValidationPluginOptions options) + { + // Get authorization challenge details from server + var client = context.Scope.Resolve<AcmeClient>(); + var authorization = await client.GetAuthorizationDetails(authorizationUri); + + // Find a targetPart that matches the challenge + var targetPart = context.Target.Parts. + FirstOrDefault(tp => tp.GetHosts(false). + Any(h => authorization.Identifier.Value == h.Replace("*.", ""))); + + return new ValidationContextParameters(authorization, targetPart, options.ChallengeType, options.Name); + } + + /// <summary> + /// Move errors from a validation context up to the renewal result + /// </summary> + /// <param name="from"></param> + /// <param name="to"></param> + /// <param name="prefix"></param> + private void TransferErrors(ValidationContext from, RenewResult to) + { + from.ErrorMessages.ForEach(e => to.AddErrorMessage($"[{from.Identifier}] {e}", from.Success == false)); + from.ErrorMessages.Clear(); + } + + + /// <summary> + /// Make sure we have authorization for every host in target + /// </summary> + /// <param name="target"></param> + /// <returns></returns> + private async Task PrepareChallengeAnswer(ValidationContext context, RunLevel runLevel) + { + if (context.ValidationPlugin == null) + { + throw new InvalidOperationException(); + } + var client = context.Scope.Resolve<AcmeClient>(); + try + { + if (context.Authorization.Status == AcmeClient.AuthorizationValid) + { + _log.Information("[{identifier}] Cached authorization result: {Status}", context.Identifier, context.Authorization.Status); + if (!runLevel.HasFlag(RunLevel.Test) && !runLevel.HasFlag(RunLevel.IgnoreCache)) + { + return; + } + // Used to make --force or --test re-validation errors non-fatal + _log.Information("[{identifier}] Handling challenge anyway because --test and/or --force is active"); + context.Success = true; + } + + _log.Information("[{identifier}] Authorizing...", context.Identifier); + _log.Verbose("[{identifier}] Initial authorization status: {status}", context.Identifier, context.Authorization.Status); + _log.Verbose("[{identifier}] Challenge types available: {challenges}", context.Identifier, context.Authorization.Challenges.Select(x => x.Type ?? "[Unknown]")); + var challenge = context.Authorization.Challenges.FirstOrDefault(c => string.Equals(c.Type, context.ChallengeType, StringComparison.CurrentCultureIgnoreCase)); + if (challenge == null) + { + if (context.Success == true) + { + var usedType = context.Authorization.Challenges. + Where(x => x.Status == AcmeClient.ChallengeValid). + FirstOrDefault(); + _log.Warning("[{identifier}] Expected challenge type {type} not available, already validated using {valided}.", + context.Identifier, + context.ChallengeType, + usedType?.Type ?? "[unknown]"); + return; + } + else + { + _log.Error("[{identifier}] Expected challenge type {type} not available.", + context.Identifier, + context.ChallengeType); + context.AddErrorMessage("Expected challenge type not available", context.Success == false); + return; + } + } + else + { + _log.Verbose("[{identifier}] Initial challenge status: {status}", context.Identifier, challenge.Status); + if (challenge.Status == AcmeClient.ChallengeValid) + { + // We actually should not get here because if one of the + // challenges is valid, the authorization itself should also + // be valid. + if (!runLevel.HasFlag(RunLevel.Test) && !runLevel.HasFlag(RunLevel.IgnoreCache)) + { + _log.Information("[{identifier}] Cached challenge result: {Status}", context.Identifier, context.Authorization.Status); + return; + } + } + } + _log.Information("[{identifier}] Authorizing using {challengeType} validation ({name})", + context.Identifier, + context.ChallengeType, + context.PluginName); + try + { + // Now that we're going to call into PrepareChallenge, we will assume + // responsibility to also call CleanUp later, which is signalled by + // the Challenge propery being not null + context.ChallengeDetails = await client.DecodeChallengeValidation(context.Authorization, challenge); + context.Challenge = challenge; + await context.ValidationPlugin.PrepareChallenge(context); + } + catch (Exception ex) + { + _log.Error(ex, "[{identifier}] Error preparing for challenge answer", context.Identifier); + context.AddErrorMessage("Error preparing for challenge answer", context.Success == false); + return; + } + } + catch (Exception ex) + { + _log.Error("[{identifier}] Error preparing challenge answer", context.Identifier); + var message = _exceptionHandler.HandleException(ex); + context.AddErrorMessage(message, context.Success == false); + } + } + + /// <summary> + /// Make sure we have authorization for every host in target + /// </summary> + /// <param name="target"></param> + /// <returns></returns> + private async Task AnswerChallenge(ValidationContext validationContext) + { + if (validationContext.Challenge == null) + { + throw new InvalidOperationException(); + } + try + { + _log.Debug("[{identifier}] Submitting challenge answer", validationContext.Identifier); + var client = validationContext.Scope.Resolve<AcmeClient>(); + var updatedChallenge = await client.AnswerChallenge(validationContext.Challenge); + validationContext.Challenge = updatedChallenge; + if (updatedChallenge.Status != AcmeClient.ChallengeValid) + { + if (updatedChallenge.Error != null) + { + _log.Error(updatedChallenge.Error.ToString()); + } + _log.Error("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); + validationContext.AddErrorMessage(updatedChallenge.Error?.ToString() ?? "Unspecified error", validationContext.Success == false); + return; + } + else + { + _log.Information("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); + return; + } + } + catch (Exception ex) + { + _log.Error("[{identifier}] Error submitting challenge answer", validationContext.Identifier); + var message = _exceptionHandler.HandleException(ex); + validationContext.AddErrorMessage(message, validationContext.Success == false); + } + } + + /// <summary> + /// Clean up after (succesful or unsuccesful) validation attempt + /// </summary> + /// <param name="validationContext"></param> + /// <returns></returns> + private async Task CleanValidation(ValidationContext validationContext) + { + if (validationContext.Challenge == null || + validationContext.ValidationPlugin == null) + { + throw new InvalidOperationException(); + } + try + { + _log.Verbose("[{identifier}] Starting post-validation cleanup", validationContext.Identifier); + await validationContext.ValidationPlugin.CleanUp(validationContext); + _log.Verbose("[{identifier}] Post-validation cleanup was succesful", validationContext.Identifier); + } + catch (Exception ex) + { + _log.Warning("[{identifier}] An error occured during post-validation cleanup: {ex}", ex.Message, validationContext.Identifier); + } + } + } +} diff --git a/src/main.lib/Services/SettingsService.cs b/src/main.lib/Services/SettingsService.cs index ab03b4e..e930c96 100644 --- a/src/main.lib/Services/SettingsService.cs +++ b/src/main.lib/Services/SettingsService.cs @@ -485,6 +485,11 @@ namespace PKISharp.WACS.Services public string? DefaultValidationMode { get; set; } /// <summary> + /// Disable multithreading for validation + /// </summary> + public bool? DisableMultiThreading { get; set; } + + /// <summary> /// If set to True, it will cleanup the folder structure /// and files it creates under the site for authorization. /// </summary> diff --git a/src/main/Program.cs b/src/main/Program.cs index 4811c3b..e02a212 100644 --- a/src/main/Program.cs +++ b/src/main/Program.cs @@ -126,6 +126,7 @@ namespace PKISharp.WACS.Host _ = builder.RegisterType<TaskSchedulerService>().SingleInstance(); _ = builder.RegisterType<NotificationService>().SingleInstance(); _ = builder.RegisterType<RenewalExecutor>().SingleInstance(); + _ = builder.RegisterType<RenewalValidator>().SingleInstance(); _ = builder.RegisterType<RenewalManager>().SingleInstance(); _ = builder.RegisterType<RenewalCreator>().SingleInstance(); _ = builder.Register(c => c.Resolve<IArgumentsService>().MainArguments).SingleInstance(); diff --git a/src/main/settings.json b/src/main/settings.json index 54d61c4..de50241 100644 --- a/src/main/settings.json +++ b/src/main/settings.json @@ -61,6 +61,7 @@ "Validation": { "DefaultValidation": null, "DefaultValidationMode": null, + "DisableMultiThreading": false, "CleanupFolders": true, "PreValidateDns": true, "PreValidateDnsRetryCount": 5, diff --git a/src/plugin.validation.dns.azure/Azure.cs b/src/plugin.validation.dns.azure/Azure.cs index f3bffd3..d60ba79 100755 --- a/src/plugin.validation.dns.azure/Azure.cs +++ b/src/plugin.validation.dns.azure/Azure.cs @@ -4,7 +4,7 @@ using Microsoft.Azure.Services.AppAuthentication; using Microsoft.Rest; using Microsoft.Rest.Azure.Authentication; using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Collections.Generic; diff --git a/src/plugin.validation.dns.cloudflare/Cloudflare.cs b/src/plugin.validation.dns.cloudflare/Cloudflare.cs index fb6a69a..0d8eb92 100644 --- a/src/plugin.validation.dns.cloudflare/Cloudflare.cs +++ b/src/plugin.validation.dns.cloudflare/Cloudflare.cs @@ -3,7 +3,7 @@ using FluentCloudflare.Api; using FluentCloudflare.Api.Entities; using FluentCloudflare.Extensions; using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Linq; diff --git a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs index 4d5f0b2..79409af 100644 --- a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs +++ b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs @@ -1,5 +1,5 @@ using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Plugins.ValidationPlugins.Dreamhost; using PKISharp.WACS.Services; using System; diff --git a/src/plugin.validation.dns.luadns/luadns.cs b/src/plugin.validation.dns.luadns/luadns.cs index c03fbd7..de6c38a 100644 --- a/src/plugin.validation.dns.luadns/luadns.cs +++ b/src/plugin.validation.dns.luadns/luadns.cs @@ -1,5 +1,5 @@ using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Collections.Generic; diff --git a/src/plugin.validation.dns.route53/Route53.cs b/src/plugin.validation.dns.route53/Route53.cs index ea0c2a2..616195b 100644 --- a/src/plugin.validation.dns.route53/Route53.cs +++ b/src/plugin.validation.dns.route53/Route53.cs @@ -1,14 +1,14 @@ -using System; -using Amazon; +using Amazon; using Amazon.Route53; using Amazon.Route53.Model; using Amazon.Runtime; using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using PKISharp.WACS.Plugins.Interfaces; namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { |