using ACMESharp.Protocol;
using ACMESharp.Protocol.Resources;
using Autofac;
using PKISharp.WACS.Clients.Acme;
using PKISharp.WACS.Configuration;
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 RenewalExecutor
{
private readonly MainArguments _args;
private readonly IAutofacBuilder _scopeBuilder;
private readonly ILifetimeScope _container;
private readonly ILogService _log;
private readonly IInputService _input;
private readonly ExceptionHandler _exceptionHandler;
public RenewalExecutor(
MainArguments args, IAutofacBuilder scopeBuilder,
ILogService log, IInputService input,
ExceptionHandler exceptionHandler, IContainer container)
{
_args = args;
_scopeBuilder = scopeBuilder;
_log = log;
_input = input;
_exceptionHandler = exceptionHandler;
_container = container;
}
public async Task Execute(Renewal renewal, RunLevel runLevel)
{
using var ts = _scopeBuilder.Target(_container, renewal, runLevel);
using var es = _scopeBuilder.Execution(ts, renewal, runLevel);
// Generate the target
var targetPlugin = es.Resolve();
var (disabled, disabledReason) = targetPlugin.Disabled;
if (disabled)
{
throw new Exception($"Target plugin is not available. {disabledReason}");
}
var target = await targetPlugin.Generate();
if (target is INull)
{
throw new Exception($"Target plugin did not generate a target");
}
if (!target.IsValid(_log))
{
throw new Exception($"Target plugin generated an invalid target");
}
// Check if our validation plugin is (still) up to the task
var validationPlugin = es.Resolve();
if (!validationPlugin.CanValidate(target))
{
throw new Exception($"Validation plugin is unable to validate the target. A wildcard host was introduced into a HTTP validated renewal.");
}
// Check if renewal is needed
if (!runLevel.HasFlag(RunLevel.ForceRenew) && !renewal.Updated)
{
_log.Verbose("Checking {renewal}", renewal.LastFriendlyName);
if (!renewal.IsDue())
{
var cs = es.Resolve();
var cache = cs.CachedInfo(renewal, target);
if (cache != null)
{
_log.Information("Renewal for {renewal} is due after {date}", renewal.LastFriendlyName, renewal.GetDueDate());
return null;
}
else if (!renewal.New)
{
_log.Information(LogType.All, "Renewal for {renewal} running prematurely due to detected target change", renewal.LastFriendlyName);
}
}
else if (!renewal.New)
{
_log.Information(LogType.All, "Renewing certificate for {renewal}", renewal.LastFriendlyName);
}
}
else if (runLevel.HasFlag(RunLevel.ForceRenew))
{
_log.Information(LogType.All, "Force renewing certificate for {renewal}", renewal.LastFriendlyName);
}
// Create the order
var client = es.Resolve();
var identifiers = target.GetHosts(false);
_log.Verbose("Creating certificate order for hosts: {identifiers}", identifiers);
var order = await client.CreateOrder(identifiers);
// Check if the order is valid
if ((order.Payload.Status != AcmeClient.OrderReady &&
order.Payload.Status != AcmeClient.OrderPending) ||
order.Payload.Error != null)
{
_log.Error("Failed to create order {url}: {detail}", order.OrderUrl, order.Payload.Error.Detail);
return OnRenewFail(new Challenge() { Error = "Unable to create order" });
}
else
{
_log.Verbose("Order {url} created", order.OrderUrl);
}
// Answer the challenges
foreach (var authUrl in order.Payload.Authorizations)
{
// Get authorization details
_log.Verbose("Handle authorization {n}/{m}",
order.Payload.Authorizations.ToList().IndexOf(authUrl) + 1,
order.Payload.Authorizations.Length + 1);
var authorization = await client.GetAuthorizationDetails(authUrl);
// Find a targetPart that matches the challenge
var targetPart = target.Parts.
FirstOrDefault(tp => tp.GetHosts(false).
Any(h => authorization.Identifier.Value == h.Replace("*.", "")));
if (targetPart == null)
{
return OnRenewFail(new Challenge()
{
Error = "Unable to match challenge to target"
});
}
// Run the validation plugin
var challenge = await Authorize(es, runLevel, renewal.ValidationPluginOptions, targetPart, authorization);
if (challenge.Status != AcmeClient.AuthorizationValid)
{
return OnRenewFail(challenge);
}
}
return await OnValidationSuccess(es, renewal, target, order, runLevel);
}
///
/// Steps to take on authorization failed
///
///
///
private RenewResult OnRenewFail(Challenge challenge)
{
var errors = challenge?.Error;
if (errors != null)
{
return new RenewResult($"Authorization failed: {errors.ToString()}");
}
else
{
return new RenewResult($"Authorization failed");
}
}
///
/// Steps to take on succesful (re)authorization
///
///
private async Task OnValidationSuccess(ILifetimeScope renewalScope, Renewal renewal, Target target, OrderDetails order, RunLevel runLevel)
{
RenewResult? result = null;
try
{
var certificateService = renewalScope.Resolve();
var csrPlugin = target.CsrBytes == null ? renewalScope.Resolve() : null;
if (csrPlugin != null)
{
var (disabled, disabledReason) = csrPlugin.Disabled;
if (disabled)
{
return new RenewResult($"CSR plugin is not available. {disabledReason}");
}
}
var oldCertificate = certificateService.CachedInfo(renewal);
var newCertificate = await certificateService.RequestCertificate(csrPlugin, runLevel, renewal, target, order);
// Test if a new certificate has been generated
if (newCertificate == null)
{
return new RenewResult("No certificate generated");
}
else
{
result = new RenewResult(newCertificate);
}
// Early escape for testing validation only
if (renewal.New &&
runLevel.HasFlag(RunLevel.Test) &&
!await _input.PromptYesNo($"[--test] Do you want to install the certificate?", true))
{
return new RenewResult("User aborted");
}
// Run store plugin(s)
var storePluginOptions = new List();
var storePlugins = new List();
try
{
var steps = renewal.StorePluginOptions.Count();
for (var i = 0; i < steps; i++)
{
var storeOptions = renewal.StorePluginOptions[i];
var storePlugin = (IStorePlugin)renewalScope.Resolve(storeOptions.Instance);
if (!(storePlugin is INull))
{
if (steps > 1)
{
_log.Information("Store step {n}/{m}: {name}...", i + 1, steps, storeOptions.Name);
}
else
{
_log.Information("Store with {name}...", storeOptions.Name);
}
var (disabled, disabledReason) = storePlugin.Disabled;
if (disabled)
{
return new RenewResult($"Store plugin is not available. {disabledReason}");
}
await storePlugin.Save(newCertificate);
storePlugins.Add(storePlugin);
storePluginOptions.Add(storeOptions);
}
}
}
catch (Exception ex)
{
var reason = _exceptionHandler.HandleException(ex, "Unable to store certificate");
result.ErrorMessage = $"Store failed: {reason}";
result.Success = false;
return result;
}
// Run installation plugin(s)
try
{
var steps = renewal.InstallationPluginOptions.Count();
for (var i = 0; i < steps; i++)
{
var installOptions = renewal.InstallationPluginOptions[i];
var installPlugin = (IInstallationPlugin)renewalScope.Resolve(
installOptions.Instance,
new TypedParameter(installOptions.GetType(), installOptions));
if (!(installPlugin is INull))
{
if (steps > 1)
{
_log.Information("Installation step {n}/{m}: {name}...", i + 1, steps, installOptions.Name);
}
else
{
_log.Information("Installing with {name}...", installOptions.Name);
}
var (disabled, disabledReason) = installPlugin.Disabled;
if (disabled)
{
return new RenewResult($"Installation plugin is not available. {disabledReason}");
}
await installPlugin.Install(storePlugins, newCertificate, oldCertificate);
}
}
}
catch (Exception ex)
{
var reason = _exceptionHandler.HandleException(ex, "Unable to install certificate");
result.Success = false;
result.ErrorMessage = $"Install failed: {reason}";
}
// Delete the old certificate if not forbidden, found and not re-used
for (var i = 0; i < storePluginOptions.Count; i++)
{
if (!storePluginOptions[i].KeepExisting &&
oldCertificate != null &&
newCertificate.Certificate.Thumbprint != oldCertificate.Certificate.Thumbprint)
{
try
{
await storePlugins[i].Delete(oldCertificate);
}
catch (Exception ex)
{
_log.Error(ex, "Unable to delete previous certificate");
//result.Success = false; // not a show-stopper, consider the renewal a success
result.ErrorMessage = $"Delete failed: {ex.Message}";
}
}
}
if ((renewal.New || renewal.Updated) && !_args.NoTaskScheduler)
{
if (runLevel.HasFlag(RunLevel.Test) &&
!await _input.PromptYesNo($"[--test] Do you want to automatically renew this certificate?", true))
{
// Early out for test runs
return new RenewResult("User aborted");
}
else
{
// Make sure the Task Scheduler is configured
await renewalScope.Resolve().EnsureTaskScheduler(runLevel, false);
}
}
return result;
}
catch (Exception ex)
{
_exceptionHandler.HandleException(ex);
while (ex.InnerException != null)
{
ex = ex.InnerException;
}
// Result might still contain the Thumbprint of the certificate
// that was requested and (partially? installed, which might help
// with debugging
if (result == null)
{
result = new RenewResult(ex.Message);
}
else
{
result.Success = false;
result.ErrorMessage = ex.Message;
}
}
return result;
}
///
/// Make sure we have authorization for every host in target
///
///
///
private async Task Authorize(
ILifetimeScope execute, RunLevel runLevel,
ValidationPluginOptions options, TargetPart targetPart,
Authorization authorization)
{
var invalid = new Challenge { Status = AcmeClient.AuthorizationInvalid };
var valid = new Challenge { Status = AcmeClient.AuthorizationValid };
var client = execute.Resolve();
var identifier = authorization.Identifier.Value;
IValidationPlugin? validationPlugin = null;
try
{
if (authorization.Status == AcmeClient.AuthorizationValid)
{
if (!runLevel.HasFlag(RunLevel.Test) &&
!runLevel.HasFlag(RunLevel.IgnoreCache))
{
_log.Information("Cached authorization result: {Status}", authorization.Status);
return valid;
}
if (runLevel.HasFlag(RunLevel.IgnoreCache))
{
// Due to the IgnoreCache flag (--force switch)
// we are going to attempt to re-authorize the
// domain even though its already autorized.
// On failure, we can still use the cached result.
// This helps for migration scenarios.
invalid = valid;
}
}
_log.Information("Authorize identifier: {identifier}", identifier);
_log.Verbose("Challenge types available: {challenges}", authorization.Challenges.Select(x => x.Type ?? "[Unknown]"));
var challenge = authorization.Challenges.FirstOrDefault(c => string.Equals(c.Type, options.ChallengeType, StringComparison.CurrentCultureIgnoreCase));
if (challenge == null)
{
if (authorization.Status == AcmeClient.AuthorizationValid)
{
var usedType = authorization.Challenges.
Where(x => x.Status == AcmeClient.AuthorizationValid).
FirstOrDefault();
_log.Warning("Expected challenge type {type} not available for {identifier}, already validated using {valided}.",
options.ChallengeType,
authorization.Identifier.Value,
usedType?.Type ?? "[unknown]");
return valid;
}
else
{
_log.Error("Expected challenge type {type} not available for {identifier}.",
options.ChallengeType,
authorization.Identifier.Value);
invalid.Error = "Expected challenge type not available";
return invalid;
}
}
// We actually have to do validation now
using var validation = _scopeBuilder.Validation(execute, options, targetPart, identifier);
try
{
validationPlugin = validation.Resolve();
}
catch (Exception ex)
{
_log.Error(ex, "Error resolving validation plugin");
}
if (validationPlugin == null)
{
_log.Error("Validation plugin not found or not created.");
invalid.Error = "Validation plugin not found or not created.";
return invalid;
}
var (disabled, disabledReason) = validationPlugin.Disabled;
if (disabled)
{
_log.Error($"Validation plugin is not available. {disabledReason}");
invalid.Error = "Validation plugin is not available.";
return invalid;
}
_log.Information("Authorizing {dnsIdentifier} using {challengeType} validation ({name})",
identifier,
options.ChallengeType,
options.Name);
try
{
var details = await client.DecodeChallengeValidation(authorization, challenge);
await validationPlugin.PrepareChallenge(details);
}
catch (Exception ex)
{
_log.Error(ex, "Error preparing for challenge answer");
invalid.Error = "Error preparing for challenge answer";
return invalid;
}
_log.Debug("Submitting challenge answer");
challenge = await client.AnswerChallenge(challenge);
if (challenge.Status != AcmeClient.AuthorizationValid)
{
if (challenge.Error != null)
{
_log.Error(challenge.Error.ToString());
}
_log.Error("Authorization result: {Status}", challenge.Status);
invalid.Error = challenge.Error;
return invalid;
}
else
{
_log.Information("Authorization result: {Status}", challenge.Status);
return valid;
}
}
catch (Exception ex)
{
_log.Error("Error authorizing {renewal}", targetPart);
_exceptionHandler.HandleException(ex);
invalid.Error = ex.Message;
return invalid;
}
finally
{
if (validationPlugin != null)
{
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);
}
}
}
}
}
}