using ACMESharp.Protocol.Resources; 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 { /// /// This part of the code handles the actual creation/renewal of ACME certificates /// 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; } /// /// Answer all the challenges in the order /// /// /// /// /// /// public async Task AuthorizeOrder(ExecutionContext context, RunLevel runLevel) { // Sanity check if (context.Order.Details == null) { context.Result.AddErrorMessage($"Unable to create order"); return; } if (context.Order.Details.Payload.Status == AcmeClient.OrderInvalid) { context.Result.AddErrorMessage($"Created order was invalid"); return; } // Maybe validation is not needed at all var orderValid = false; if (context.Order.Details.Payload.Status == AcmeClient.OrderReady || context.Order.Details.Payload.Status == AcmeClient.OrderValid) { if (!runLevel.HasFlag(RunLevel.Test) && !runLevel.HasFlag(RunLevel.IgnoreCache)) { return; } else { orderValid = true; } } // Get validation plugin var options = context.Renewal.ValidationPluginOptions; var validationScope = _scopeBuilder.Validation(context.Scope, options); var validationPlugin = validationScope.Resolve(); if (validationPlugin == null) { _log.Error("Validation plugin not found or not created"); context.Result.AddErrorMessage("Validation plugin not found or not created", !orderValid); return; } var (disabled, disabledReason) = validationPlugin.Disabled; if (disabled) { _log.Error($"Validation plugin is not available. {disabledReason}"); context.Result.AddErrorMessage("Validation plugin is not available", !orderValid); return; } // Get authorization details var authorizations = context.Order.Details.Payload.Authorizations.ToList(); var contextParamTasks = authorizations.Select(authorizationUri => GetValidationContextParameters(context, authorizationUri, options, orderValid)); var contextParams = (await Task.WhenAll(contextParamTasks)).OfType().ToList(); if (!context.Result.Success) { return; } if (_settings.Validation.DisableMultiThreading == true || validationPlugin.Parallelism == ParallelOperations.None) { await SerialValidation(context, contextParams); } else { await ParallelValidation(validationPlugin.Parallelism, validationScope, context, contextParams); } } /// + /// Handle multiple validations in parallel /// /// private async Task ParallelValidation(ParallelOperations level, ILifetimeScope scope, ExecutionContext context, List parameters) { var contexts = parameters.Select(parameter => new ValidationContext(scope, parameter)).ToList(); var plugin = contexts.First().ValidationPlugin; try { // 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; } } } // Commit var commited = await CommitValidation(plugin); if (!commited) { context.Result.AddErrorMessage("Commit failed"); return; } // Submit challenge answer var contextsWithChallenges = contexts.Where(x => x.ChallengeDetails != null); if (contextsWithChallenges.Any()) { if (level.HasFlag(ParallelOperations.Answer)) { // Parallel _log.Verbose("Handle {n} answers(s)", contextsWithChallenges.Count()); var answerTasks = contexts.Select(vc => AnswerChallenge(vc)); await Task.WhenAll(answerTasks); foreach (var ctx in contextsWithChallenges) { TransferErrors(ctx, context.Result); } if (!context.Result.Success) { return; } } else { // Serial foreach (var ctx in contextsWithChallenges) { await AnswerChallenge(ctx); TransferErrors(ctx, context.Result); if (!context.Result.Success) { return; } } } } } finally { // Cleanup await CleanValidation(plugin); } } /// /// Handle validation in serial order /// /// /// /// private async Task SerialValidation(ExecutionContext context, List 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 { parameter }); if (!context.Result.Success) { break; } } } /// /// Get information needed to construct a validation context (shared between serial and parallel mode) /// /// /// /// /// private async Task GetValidationContextParameters(ExecutionContext context, string authorizationUri, ValidationPluginOptions options, bool orderValid) { // Get authorization challenge details from server var client = context.Scope.Resolve(); var authorization = default(Authorization); try { authorization = await client.GetAuthorizationDetails(authorizationUri); } catch { context.Result.AddErrorMessage($"Unable to get authorization details from {authorizationUri}", !orderValid); return null; } // 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 {authorization.Identifier.Value} to target", !orderValid); return null; } return new ValidationContextParameters(authorization, targetPart, options.ChallengeType, options.Name, orderValid); } /// /// Move errors from a validation context up to the renewal result /// /// /// /// private void TransferErrors(ValidationContext from, RenewResult to) { from.ErrorMessages.ForEach(e => to.AddErrorMessage($"[{from.Identifier}] {e}", from.Success != true)); from.ErrorMessages.Clear(); } /// /// Make sure we have authorization for every host in target /// /// /// private async Task PrepareChallengeAnswer(ValidationContext context, RunLevel runLevel) { if (context.ValidationPlugin == null) { throw new InvalidOperationException("No validation plugin configured"); } var client = context.Scope.Resolve(); 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.Identifier); 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.InvariantCultureIgnoreCase)); 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); } } /// /// Make sure we have authorization for every host in target /// /// /// private async Task AnswerChallenge(ValidationContext validationContext) { if (validationContext.Challenge == null) { throw new InvalidOperationException("No challenge found"); } try { _log.Debug("[{identifier}] Submitting challenge answer", validationContext.Identifier); var client = validationContext.Scope.Resolve(); var updatedChallenge = await client.AnswerChallenge(validationContext.Challenge); validationContext.Challenge = updatedChallenge; if (updatedChallenge.Status != AcmeClient.ChallengeValid) { _log.Error("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); if (updatedChallenge.Error != null) { _log.Error("[{identifier}] {Error}", validationContext.Identifier, updatedChallenge.Error.ToString()); } validationContext.AddErrorMessage("Validation failed", validationContext.Success != true); return; } else { validationContext.Success = true; _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 != true); } } /// /// Clean up after (succesful or unsuccesful) validation attempt /// /// /// private async Task CommitValidation(IValidationPlugin validationPlugin) { try { _log.Verbose("Starting commit stage"); await validationPlugin.Commit(); _log.Verbose("Commit was succesful"); return true; } catch (Exception ex) { _log.Error(ex, "An error occured while commiting validation configuration: {ex}", ex.Message); return false; } } /// /// Clean up after (succesful or unsuccesful) validation attempt /// /// /// private async Task CleanValidation(IValidationPlugin validationPlugin) { try { _log.Verbose("Starting post-validation cleanup"); await validationPlugin.CleanUp(); _log.Verbose("Post-validation cleanup was succesful"); } catch (Exception ex) { _log.Warning("An error occured during post-validation cleanup: {ex}", ex.Message); } } } }