diff options
author | Wouter Tinus <win.acme.simple@gmail.com> | 2020-07-10 13:22:30 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-07-10 13:22:30 +0200 |
commit | 594d90be29ec6090668966ad2fe2c9c9d46cace7 (patch) | |
tree | b34d975bd0ae3aff1e46843e41a6bc7fe892fdc7 | |
parent | e791885b06499c2cdd689bd7a7ed77fa8834a0cf (diff) | |
parent | b2cea77b69fb05710f5f20f388878e92e213499d (diff) | |
download | letsencrypt-win-simple-594d90be29ec6090668966ad2fe2c9c9d46cace7.zip letsencrypt-win-simple-594d90be29ec6090668966ad2fe2c9c9d46cace7.tar.gz letsencrypt-win-simple-594d90be29ec6090668966ad2fe2c9c9d46cace7.tar.bz2 |
Merge pull request #1609 from win-acme/2.1.9v2.1.9
2.1.9
44 files changed, 1318 insertions, 643 deletions
diff --git a/appveyor.yml b/appveyor.yml index 0af7350..ff2cc11 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 2.1.8.{build} +version: 2.1.9.{build} image: Visual Studio 2019 platform: Any CPU shallow_clone: true diff --git a/src/ACMESharpCore b/src/ACMESharpCore -Subproject 2e6dcd3f01696c5ed18483f53c670031f0d81c0 +Subproject bd6f0bbc0b0e1cb17303324a2a0b4c657a045ed diff --git a/src/main.lib/Clients/Acme/AcmeClient.cs b/src/main.lib/Clients/Acme/AcmeClient.cs index 2783881..864ab7d 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,10 @@ namespace PKISharp.WACS.Clients.Acme /// <returns></returns>
private async Task<T> Retry<T>(Func<Task<T>> executor, int attempt = 0)
{
+ if (attempt == 0)
+ {
+ await _requestLock.WaitAsync();
+ }
try
{
return await executor();
@@ -547,8 +552,21 @@ namespace PKISharp.WACS.Clients.Acme throw;
}
}
+ finally
+ {
+ if (attempt == 0)
+ {
+ _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/Clients/EmailClient.cs b/src/main.lib/Clients/EmailClient.cs index 5e78920..4f77115 100644 --- a/src/main.lib/Clients/EmailClient.cs +++ b/src/main.lib/Clients/EmailClient.cs @@ -66,13 +66,13 @@ namespace PKISharp.WACS.Clients public bool Enabled { get; internal set; } - public void Send(string subject, string content, MessagePriority priority) + public async Task Send(string subject, string content, MessagePriority priority) { if (Enabled) { + using var client = new SmtpClient(); try { - using var client = new SmtpClient(); var options = SecureSocketOptions.None; if (_secure) { @@ -99,10 +99,10 @@ namespace PKISharp.WACS.Clients options = SecureSocketOptions.StartTls; } } - client.Connect(_server, _port, options); + await client.ConnectAsync(_server, _port, options); if (!string.IsNullOrEmpty(_user)) { - client.Authenticate(new NetworkCredential(_user, _password)); + await client.AuthenticateAsync(new NetworkCredential(_user, _password)); } foreach (var receiverAddress in _receiverAddresses) { @@ -121,17 +121,22 @@ namespace PKISharp.WACS.Clients var bodyBuilder = new BodyBuilder(); bodyBuilder.HtmlBody = content + $"<p>Sent by win-acme version {_version} from {_computerName}</p>"; message.Body = bodyBuilder.ToMessageBody(); - client.Send(message); + await client.SendAsync(message); + await client.DisconnectAsync(true); } } catch (Exception ex) { _log.Error(ex, "Problem sending e-mail"); + } + finally + { + } } } - internal Task Test() + internal async Task Test() { if (!Enabled) { @@ -140,12 +145,11 @@ namespace PKISharp.WACS.Clients else { _log.Information("Sending test message..."); - Send("Test notification", + await Send("Test notification", "<p>If you are reading this, it means you will receive notifications about critical errors in the future.</p>", MessagePriority.Normal); _log.Information("Test message sent!"); } - return Task.CompletedTask; } } } diff --git a/src/main.lib/Clients/IIS/IISClient.cs b/src/main.lib/Clients/IIS/IISClient.cs index 6830086..3efa22a 100644 --- a/src/main.lib/Clients/IIS/IISClient.cs +++ b/src/main.lib/Clients/IIS/IISClient.cs @@ -40,7 +40,14 @@ namespace PKISharp.WACS.Clients.IIS { if (Version.Major > 0) { - _serverManager = new ServerManager(); + try + { + _serverManager = new ServerManager(); + } + catch + { + _log.Error($"Unable to create an IIS ServerManager"); + } _webSites = null; _ftpSites = null; } 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..4dddb76 --- /dev/null +++ b/src/main.lib/Context/ValidationContext.cs @@ -0,0 +1,77 @@ +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, + bool orderValid) + { + TargetPart = targetPart; + Authorization = authorization; + ChallengeType = challengeType; + PluginName = pluginName; + OrderValid = orderValid; + } + public bool OrderValid { get; } + 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>(); + if (parameters.OrderValid) + { + Success = true; + } + } + 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 ba899db..a7c8249 100644 --- a/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs +++ b/src/main.lib/Plugins/Interfaces/IValidationPlugin.cs @@ -1,4 +1,4 @@ -using ACMESharp.Authorizations; +using PKISharp.WACS.Context; using System; using System.Threading.Tasks; @@ -10,17 +10,37 @@ namespace PKISharp.WACS.Plugins.Interfaces public interface IValidationPlugin : IPlugin { /// <summary> - /// Prepare challenge + /// Prepare single challenge /// </summary> /// <param name="options"></param> /// <param name="target"></param> /// <param name="challenge"></param> /// <returns></returns> - Task PrepareChallenge(IChallengeValidationDetails challengeDetails); + Task PrepareChallenge(ValidationContext context); + + /// <summary> + /// Commit changes after all the challenges have been prepared + /// </summary> + /// <returns></returns> + Task Commit(); /// <summary> /// Clean up after validation attempt /// </summary> - Task CleanUp(); + Task CleanUp(); + + /// <summary> + /// Indicate level of supported parallelism + /// </summary> + ParallelOperations Parallelism { get; } + } + + [Flags] + public enum ParallelOperations + { + None = 0, + Prepare = 1, + Answer = 2, + Clean = 4 } } diff --git a/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs b/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs index 257e88d..dc96981 100644 --- a/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs +++ b/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs @@ -185,6 +185,16 @@ namespace PKISharp.WACS.Plugins.Resolvers /// <returns></returns> public override async Task<IValidationPluginOptionsFactory> GetValidationPlugin(ILifetimeScope scope, Target target) { + var defaultParam1 = _settings.Validation.DefaultValidation; + var defaultParam2 = _settings.Validation.DefaultValidationMode ?? Constants.Http01ChallengeType; + if (!string.IsNullOrWhiteSpace(_arguments.MainArguments.Validation)) + { + defaultParam1 = _arguments.MainArguments.Validation; + } + if (!string.IsNullOrWhiteSpace(_arguments.MainArguments.ValidationMode)) + { + defaultParam2 = _arguments.MainArguments.ValidationMode; + } return await GetPlugin<IValidationPluginOptionsFactory>( scope, sort: x => @@ -203,8 +213,8 @@ namespace PKISharp.WACS.Plugins.Resolvers ThenBy(x => x.Description), unusable: x => (!x.CanValidate(target), "Unsuppored target. Most likely this is because you have included a wildcard identifier (*.example.com), which requires DNS validation."), description: x => $"[{x.ChallengeType}] {x.Description}", - defaultParam1: _settings.Validation.DefaultValidation, - defaultParam2: _settings.Validation.DefaultValidationMode ?? Constants.Http01ChallengeType, + defaultParam1: defaultParam1, + defaultParam2: defaultParam2, defaultType: typeof(SelfHostingOptionsFactory), defaultTypeFallback: typeof(FileSystemOptionsFactory), nullResult: new NullValidationFactory(), @@ -250,6 +260,10 @@ namespace PKISharp.WACS.Plugins.Resolvers defaultType = typeof(NullStoreOptionsFactory); } var defaultParam1 = _settings.Store.DefaultStore; + if (!string.IsNullOrWhiteSpace(_arguments.MainArguments.Store)) + { + defaultParam1 = _arguments.MainArguments.Store; + } var csv = defaultParam1.ParseCsv(); defaultParam1 = csv?.Count > chosen.Count() ? csv[chosen.Count()] : @@ -292,6 +306,10 @@ namespace PKISharp.WACS.Plugins.Resolvers defaultType = typeof(NullInstallationOptionsFactory); } var defaultParam1 = _settings.Installation.DefaultInstallation; + if (!string.IsNullOrWhiteSpace(_arguments.MainArguments.Installation)) + { + defaultParam1 = _arguments.MainArguments.Installation; + } var csv = defaultParam1.ParseCsv(); defaultParam1 = csv?.Count > chosen.Count() ? csv[chosen.Count()] : diff --git a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs index 6da551c..a9e3a48 100644 --- a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs +++ b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs @@ -19,7 +19,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins public static string? DefaultPath(ISettingsService settings) { - var ret = settings.Store.PemFiles?.DefaultPath; + var ret = settings.Store.CentralSsl?.DefaultPath; if (string.IsNullOrWhiteSpace(ret)) { ret = settings.Store.DefaultCentralSslStore; diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs index 8cc998e..00359b4 100644 --- a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs @@ -28,7 +28,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins var path = args?.PfxFilePath; if (string.IsNullOrWhiteSpace(path)) { - path = _settings.Store.PfxFile?.DefaultPassword; + path = _settings.Store.PfxFile?.DefaultPath; } while (string.IsNullOrWhiteSpace(path) || !path.ValidPath(_log)) { diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs index ddcff59..e89e289 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs @@ -1,5 +1,6 @@ using PKISharp.WACS.Clients; using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Threading.Tasks; @@ -11,7 +12,6 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns private readonly IInputService _input; private readonly ProxyService _proxy; private readonly AcmeOptions _options; - private readonly string _identifier; public Acme( LookupClientProvider dnsClient, @@ -19,12 +19,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns ISettingsService settings, IInputService input, ProxyService proxy, - AcmeOptions options, - string identifier) : + AcmeOptions options) : base(dnsClient, log, settings) { _options = options; - _identifier = identifier; _input = input; _proxy = proxy; } @@ -34,12 +32,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns /// </summary> /// <param name="recordName"></param> /// <param name="token"></param> - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { var client = new AcmeDnsClient(_dnsClient, _proxy, _log, _settings, _input, new Uri(_options.BaseUri)); - return await client.Update(_identifier, token); + return await client.Update(record.Context.Identifier, record.Value); } - public override Task DeleteRecord(string recordName, string token) => Task.CompletedTask; + public override Task DeleteRecord(DnsValidationRecord record) => Task.CompletedTask; } } diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs index 87e5273..0c39ec7 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs @@ -1,10 +1,10 @@ using ACMESharp.Authorizations;
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;
using System.Threading.Tasks;
using static PKISharp.WACS.Clients.DNS.LookupClientProvider;
@@ -18,10 +18,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins protected readonly LookupClientProvider _dnsClient;
protected readonly ILogService _log;
protected readonly ISettingsService _settings;
- private DnsLookupResult? _authority;
+ private readonly List<DnsValidationRecord> _recordsCreated = new List<DnsValidationRecord>();
protected DnsValidation(
- LookupClientProvider dnsClient,
+ LookupClientProvider dnsClient,
ILogService log,
ISettingsService settings)
{
@@ -30,124 +30,179 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins _settings = settings;
}
- public override async Task PrepareChallenge()
+ /// <summary>
+ /// Prepare to add a new DNS record
+ /// </summary>
+ /// <param name="context"></param>
+ /// <param name="challenge"></param>
+ /// <returns></returns>
+ public override async Task PrepareChallenge(ValidationContext context, Dns01ChallengeValidationDetails challenge)
{
// Check for substitute domains
- _authority = await _dnsClient.GetAuthority(
- Challenge.DnsRecordName,
+ var authority = await _dnsClient.GetAuthority(
+ challenge.DnsRecordName,
followCnames: _settings.Validation.AllowDnsSubstitution);
var success = false;
- while (!success)
+ while (!success)
{
- success = await CreateRecord(_authority.Domain, Challenge.DnsRecordValue);
+ var record = new DnsValidationRecord(context, authority, challenge.DnsRecordValue);
+ success = await CreateRecord(record);
if (!success)
{
- if (_authority.From == null)
+ if (authority.From == null)
{
throw new Exception("Unable to prepare for challenge answer");
}
else
{
- _authority = _authority.From;
+ authority = authority.From;
}
+ }
+ else
+ {
+ _recordsCreated.Add(record);
}
- }
+ }
+ }
+
+ /// <summary>
+ /// Default commit function, doesn't do anything because
+ /// default doesn't do parallel operation
+ /// </summary>
+ /// <returns></returns>
+ public override sealed async Task Commit()
+ {
+ // Wait for changes to be saved
+ await SaveChanges();
// Verify that the record was created succesfully and wait for possible
// propagation/caching/TTL issues to resolve themselves naturally
- var retry = 0;
- var maxRetries = _settings.Validation.PreValidateDnsRetryCount;
- var retrySeconds = _settings.Validation.PreValidateDnsRetryInterval;
- while (_settings.Validation.PreValidateDns)
+ if (_settings.Validation.PreValidateDns)
{
- if (await PreValidate())
- {
- break;
- }
- else
- {
- retry += 1;
- if (retry > maxRetries)
- {
- _log.Information("It looks like validation is going to fail, but we will try now anyway...");
- break;
- }
- else
- {
- _log.Information("Will retry in {s} seconds (retry {i}/{j})...", retrySeconds, retry, maxRetries);
- Thread.Sleep(retrySeconds * 1000);
- }
- }
+ var validationTasks = _recordsCreated.Select(r => ValidateRecord(r));
+ await Task.WhenAll(validationTasks);
}
}
- protected async Task<bool> PreValidate()
+ /// <summary>
+ /// Typically the changes will already be saved by
+ /// PrepareChallenge, but for those plugins that support
+ /// parallel operation, this may be overridden to handle
+ /// persistance
+ /// </summary>
+ /// <returns></returns>
+ public virtual Task SaveChanges() => Task.CompletedTask;
+
+ /// <summary>
+ /// Check the TXT value from all known authoritative DNS servers
+ /// </summary>
+ /// <param name="record"></param>
+ /// <returns></returns>
+ protected async Task<bool> PreValidate(DnsValidationRecord record)
{
try
{
- if (_authority == null)
- {
- throw new InvalidOperationException("_recordName is null");
- }
- _log.Debug("Looking for TXT value {DnsRecordValue}...", _authority.Domain);
- foreach (var client in _authority.Nameservers)
+ _log.Debug("[{identifier}] Looking for TXT value {DnsRecordValue}...", record.Context.Identifier, record.Authority.Domain);
+ foreach (var client in record.Authority.Nameservers)
{
- _log.Debug("Preliminary validation asking {ip}...", client.IpAddress);
- var answers = await client.GetTxtRecords(_authority.Domain);
+ _log.Debug("[{identifier}] Preliminary validation asking {ip}...", record.Context.Identifier, client.IpAddress);
+ var answers = await client.GetTxtRecords(record.Authority.Domain);
if (!answers.Any())
{
- _log.Warning("Preliminary validation failed: no TXT records found");
+ _log.Warning("[{identifier}] Preliminary validation failed: no TXT records found", record.Context.Identifier);
return false;
}
- if (!answers.Contains(Challenge.DnsRecordValue))
+ if (!answers.Contains(record.Value))
{
- _log.Debug("Preliminary validation found values: {answers}", answers);
- _log.Warning("Preliminary validation failed: incorrect TXT record(s) found");
+ _log.Debug("[{identifier}] Preliminary validation found values: {answers}", record.Context.Identifier, answers);
+ _log.Warning("[{identifier}] Preliminary validation failed: incorrect TXT record(s) found", record.Context.Identifier);
return false;
}
- _log.Debug("Preliminary validation from {ip} looks good", client.IpAddress);
+ _log.Debug("[{identifier}] Preliminary validation from {ip} looks good", record.Context.Identifier, client.IpAddress);
}
}
catch (Exception ex)
{
- _log.Error(ex, "Preliminary validation failed");
+ _log.Error(ex, "[{identifier}] Preliminary validation failed", record.Context.Identifier);
return false;
}
- _log.Information("Preliminary validation succeeded");
+ _log.Information("[{identifier}] Preliminary validation succeeded", record.Context.Identifier);
return true;
}
/// <summary>
/// Delete record when we're done
/// </summary>
- public override async Task CleanUp()
+ public override sealed async Task CleanUp()
{
- if (HasChallenge && _authority != null)
+ foreach (var record in _recordsCreated)
{
try
{
- await DeleteRecord(_authority.Domain, Challenge.DnsRecordValue);
- }
+ await DeleteRecord(record);
+ }
catch (Exception ex)
{
_log.Warning($"Error deleting record: {ex.Message}");
}
}
+ await Finalize();
+ }
+
+ /// <summary>
+ /// Typically the changes will already be undone by
+ /// Finalize, but for those plugins that support
+ /// parallel operation, this may be overridden
+ /// </summary>
+ /// <returns></returns>
+ public virtual Task Finalize() => Task.CompletedTask;
+
+ /// <summary>
+ /// Validate a record as being correctly created an sychronised, runs during/after the commit state
+ /// </summary>
+ /// <param name="record"></param>
+ /// <returns></returns>
+ private async Task ValidateRecord(DnsValidationRecord record)
+ {
+ var retry = 0;
+ var maxRetries = _settings.Validation.PreValidateDnsRetryCount;
+ var retrySeconds = _settings.Validation.PreValidateDnsRetryInterval;
+ while (true)
+ {
+ if (await PreValidate(record))
+ {
+ break;
+ }
+ else
+ {
+ retry += 1;
+ if (retry > maxRetries)
+ {
+ _log.Information("It looks like validation is going to fail, but we will try now anyway...");
+ break;
+ }
+ else
+ {
+ _log.Information("Will retry in {s} seconds (retry {i}/{j})...", retrySeconds, retry, maxRetries);
+ await Task.Delay(retrySeconds * 1000);
+ }
+ }
+ }
}
/// <summary>
/// Delete validation record
/// </summary>
/// <param name="recordName">Name of the record</param>
- public abstract Task DeleteRecord(string recordName, string token);
+ public abstract Task DeleteRecord(DnsValidationRecord record);
/// <summary>
/// Create validation record
/// </summary>
/// <param name="recordName">Name of the record</param>
/// <param name="token">Contents of the record</param>
- public abstract Task<bool> CreateRecord(string recordName, string token);
+ public abstract Task<bool> CreateRecord(DnsValidationRecord record);
/// <summary>
/// Match DNS zone to use from a list of all zones
@@ -196,5 +251,22 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins return null;
}
}
+
+ /// <summary>
+ /// Keep track of which records are created, so that they can be deleted later
+ /// </summary>
+ public class DnsValidationRecord
+ {
+ public ValidationContext Context { get; }
+ public DnsLookupResult Authority { get; }
+ public string Value { get; }
+
+ public DnsValidationRecord(ValidationContext context, DnsLookupResult authority, string value)
+ {
+ Context = context;
+ Authority = authority;
+ Value = value;
+ }
+ }
}
}
diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs index c584efb..880eb6c 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Manual/Manual.cs @@ -1,4 +1,6 @@ using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; +using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System.Threading.Tasks; @@ -7,31 +9,29 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns internal class Manual : DnsValidation<Manual> { private readonly IInputService _input; - private readonly string _identifier; + + public override ParallelOperations Parallelism => ParallelOperations.Answer; public Manual( LookupClientProvider dnsClient, ILogService log, IInputService input, - ISettingsService settings, - string identifier) - : base(dnsClient, log, settings) + ISettingsService settings) : base(dnsClient, log, settings) { // Usually it's a big no-no to rely on user input in validation plugin // because this should be able to run unattended. This plugin is for testing // only and therefor we will allow it. Future versions might be more advanced, // e.g. shoot an email to an admin and complete the order later. _input = input; - _identifier = identifier; } - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { _input.CreateSpace(); - _input.Show("Domain", _identifier); - _input.Show("Record", recordName); + _input.Show("Domain", record.Context.Identifier); + _input.Show("Record", record.Authority.Domain); _input.Show("Type", "TXT"); - _input.Show("Content", $"\"{token}\""); + _input.Show("Content", $"\"{record.Value}\""); _input.Show("Note", "Some DNS managers add quotes automatically. A single set is needed."); if (!await _input.Wait("Please press <Enter> after you've created and verified the record")) { @@ -42,7 +42,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns // Pre-pre-validate, allowing the manual user to correct mistakes while (true) { - if (await PreValidate()) + if (await PreValidate(record)) { return true; } @@ -61,13 +61,13 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - public override Task DeleteRecord(string recordName, string token) + public override Task DeleteRecord(DnsValidationRecord record) { _input.CreateSpace(); - _input.Show("Domain", _identifier); - _input.Show("Record", recordName); + _input.Show("Domain", record.Context.Identifier); + _input.Show("Record", record.Authority.Domain); _input.Show("Type", "TXT"); - _input.Show("Content", $"\"{token}\""); + _input.Show("Content", $"\"{record.Value}\""); _input.Wait("Please press <Enter> after you've deleted the record"); return Task.CompletedTask; } diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs index 80ece5e..222a7d2 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/Script.cs @@ -9,8 +9,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { private readonly ScriptClient _scriptClient; private readonly ScriptOptions _options; - private readonly string _identifier; - + private readonly DomainParseService _domainParseService; internal const string DefaultCreateArguments = "create {Identifier} {RecordName} {Token}"; internal const string DefaultDeleteArguments = "delete {Identifier} {RecordName} {Token}"; @@ -19,16 +18,16 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns LookupClientProvider dnsClient, ScriptClient client, ILogService log, - ISettingsService settings, - string identifier) : + DomainParseService domainParseService, + ISettingsService settings) : base(dnsClient, log, settings) { - _identifier = identifier; _options = options; _scriptClient = client; + _domainParseService = domainParseService; } - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { var script = _options.Script ?? _options.CreateScript; if (!string.IsNullOrWhiteSpace(script)) @@ -38,7 +37,14 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { args = _options.CreateScriptArguments; } - await _scriptClient.RunScript(script, ProcessArguments(recordName, token, args, script.EndsWith(".ps1"))); + await _scriptClient.RunScript( + script, + ProcessArguments( + record.Context.Identifier, + record.Authority.Domain, + record.Value, + args, + script.EndsWith(".ps1"))); return true; } else @@ -48,7 +54,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { var script = _options.Script ?? _options.DeleteScript; if (!string.IsNullOrWhiteSpace(script)) @@ -58,7 +64,14 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { args = _options.DeleteScriptArguments; } - await _scriptClient.RunScript(script, ProcessArguments(recordName, token, args, script.EndsWith(".ps1"))); + await _scriptClient.RunScript( + script, + ProcessArguments( + record.Context.Identifier, + record.Authority.Domain, + record.Value, + args, + script.EndsWith(".ps1"))); } else { @@ -66,11 +79,29 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - private string ProcessArguments(string recordName, string token, string args, bool escapeToken) + private string ProcessArguments(string identifier, string recordName, string token, string args, bool escapeToken) { var ret = args; - ret = ret.Replace("{Identifier}", _identifier); + // recordName: _acme-challenge.sub.domain.com + // zoneName: domain.com + // nodeName: _acme-challenge.sub + + // recordName: domain.com + // zoneName: domain.com + // nodeName: @ + + var zoneName = _domainParseService.GetRegisterableDomain(identifier); + var nodeName = "@"; + if (recordName != zoneName) + { + // Offset by one to prevent trailing dot + nodeName = recordName.Substring(0, recordName.Length - zoneName.Length - 1); + } + ret = ret.Replace("{ZoneName}", zoneName); + ret = ret.Replace("{NodeName}", nodeName); + ret = ret.Replace("{Identifier}", identifier); ret = ret.Replace("{RecordName}", recordName); + // Some tokens start with - which confuses Powershell. We did not want to // make a breaking change for .bat or .exe files, so instead escape the // token with double quotes, as Powershell discards the quotes anyway and diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs index f8d5b0b..8dfccb4 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs @@ -1,7 +1,9 @@ using PKISharp.WACS.Clients.IIS; +using PKISharp.WACS.DomainObjects; using System; using System.IO; using System.Linq; +using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins.Http { @@ -11,7 +13,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http public FileSystem(FileSystemOptions options, IIISClient iisClient, RunLevel runLevel, HttpValidationParameters pars) : base(options, runLevel, pars) => _iisClient = iisClient; - protected override void DeleteFile(string path) + protected override Task DeleteFile(string path) { var fi = new FileInfo(path); if (fi.Exists) @@ -23,9 +25,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http { _log.Warning("File {path} already deleted", path); } + return Task.CompletedTask; } - protected override void DeleteFolder(string path) + protected override Task DeleteFolder(string path) { var di = new DirectoryInfo(path); if (di.Exists) @@ -37,11 +40,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http { _log.Warning("Folder {path} already deleted", path); } + return Task.CompletedTask; } - protected override bool IsEmpty(string path) => !new DirectoryInfo(path).EnumerateFileSystemInfos().Any(); + protected override Task<bool> IsEmpty(string path) => Task.FromResult(!new DirectoryInfo(path).EnumerateFileSystemInfos().Any()); - protected override void WriteFile(string path, string content) + protected override async Task WriteFile(string path, string content) { var fi = new FileInfo(path); if (!fi.Directory.Exists) @@ -49,19 +53,19 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http fi.Directory.Create(); } _log.Verbose("Writing file to {path}", path); - File.WriteAllText(path, content); + await File.WriteAllTextAsync(path, content); } /// <summary> /// Update webroot /// </summary> /// <param name="scheduled"></param> - protected override void Refresh() + protected override void Refresh(TargetPart targetPart) { if (string.IsNullOrEmpty(_options.Path)) { // Update web root path - var siteId = _options.SiteId ?? _targetPart.SiteId; + var siteId = _options.SiteId ?? targetPart.SiteId; if (siteId > 0) { _path = _iisClient.GetWebSite(siteId.Value).Path; diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/Ftp/Ftp.cs b/src/main.lib/Plugins/ValidationPlugins/Http/Ftp/Ftp.cs index 6dbde44..c9aa07e 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/Ftp/Ftp.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/Ftp/Ftp.cs @@ -12,12 +12,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http protected override char PathSeparator => '/'; - protected override void DeleteFile(string path) => _ftpClient.Delete(path, FtpClient.FileType.File); + protected override async Task DeleteFile(string path) => _ftpClient.Delete(path, FtpClient.FileType.File); - protected override void DeleteFolder(string path) => _ftpClient.Delete(path, FtpClient.FileType.Directory); + protected override async Task DeleteFolder(string path) => _ftpClient.Delete(path, FtpClient.FileType.Directory); - protected override bool IsEmpty(string path) => !_ftpClient.GetFiles(path).Any(); + protected override async Task<bool> IsEmpty(string path) => !_ftpClient.GetFiles(path).Any(); - protected override void WriteFile(string path, string content) => _ftpClient.Upload(path, content); + protected override async Task WriteFile(string path, string content) => _ftpClient.Upload(path, content); } } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs index bc811fe..ee6a996 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs @@ -1,8 +1,10 @@ using ACMESharp.Authorizations; +using PKISharp.WACS.Context; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -19,9 +21,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins where TOptions : HttpValidationOptions<TPlugin> where TPlugin : IValidationPlugin { - - private bool _webConfigWritten = false; - private bool _challengeWritten = false; + private readonly List<string> _filesWritten = new List<string>(); protected TOptions _options; protected ILogService _log; @@ -31,6 +31,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins protected RunLevel _runLevel; /// <summary> + /// Multiple http-01 validation challenges can be answered at the same time + /// </summary> + public override ParallelOperations Parallelism => ParallelOperations.Answer; + + /// <summary> /// Path used for the current renewal, may not be same as _options.Path /// because of the "Split" function employed by IISSites target /// </summary> @@ -42,13 +47,6 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins private readonly ProxyService _proxy; /// <summary> - /// Current TargetPart that we are working on. A TargetPart is mainly used by - /// the IISSites TargetPlugin to indicate that we are working with different - /// IIS sites - /// </summary> - protected TargetPart _targetPart; - - /// <summary> /// Where to find the template for the web.config that's copied to the webroot /// </summary> protected string TemplateWebConfig => Path.Combine(Path.GetDirectoryName(_settings.ExePath), "web_config.xml"); @@ -79,25 +77,29 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins _proxy = pars.ProxyService; _settings = pars.Settings; _renewal = pars.Renewal; - _targetPart = pars.TargetPart; } /// <summary> /// Handle http challenge /// </summary> - public async override Task PrepareChallenge() + public async override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) { - Refresh(); - WriteAuthorizationFile(); - WriteWebConfig(); - _log.Information("Answer should now be browsable at {answerUri}", Challenge.HttpResourceUrl); + // 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); if (_runLevel.HasFlag(RunLevel.Test) && _renewal.New) { if (await _input.PromptYesNo("[--test] Try in default browser?", false)) { Process.Start(new ProcessStartInfo { - FileName = Challenge.HttpResourceUrl, + FileName = challenge.HttpResourceUrl, UseShellExecute = true }); await _input.Wait(); @@ -107,8 +109,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins string? foundValue = null; try { - var value = await WarmupSite(); - if (Equals(value, Challenge.HttpResourceValue)) + var value = await WarmupSite(challenge); + if (Equals(value, challenge.HttpResourceValue)) { _log.Information("Preliminary validation looks good, but the ACME server will be more thorough"); } @@ -116,7 +118,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins { _log.Warning("Preliminary validation failed, the server answered '{value}' instead of '{expected}'. The ACME server might have a different perspective", foundValue ?? "(null)", - Challenge.HttpResourceValue); + challenge.HttpResourceValue); } } catch (HttpRequestException hrex) @@ -130,15 +132,22 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins } /// <summary> + /// Default commit function, doesn't do anything because + /// default doesn't do parallel operation + /// </summary> + /// <returns></returns> + public override Task Commit() => Task.CompletedTask; + + /// <summary> /// Warm up the target site, giving the application a little /// time to start up before the validation request comes in. /// Mostly relevant to classic FileSystem validation /// </summary> /// <param name="uri"></param> - private async Task<string> WarmupSite() + private async Task<string> WarmupSite(Http01ChallengeValidationDetails challenge) { using var client = _proxy.GetHttpClient(false); - var response = await client.GetAsync(Challenge.HttpResourceUrl); + var response = await client.GetAsync(challenge.HttpResourceUrl); return await response.Content.ReadAsStringAsync(); } @@ -147,14 +156,18 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// </summary> /// <param name="answerPath">where the answerFile should be located</param> /// <param name="fileContents">the contents of the file to write</param> - private void WriteAuthorizationFile() + private void WriteAuthorizationFile(Http01ChallengeValidationDetails challenge) { if (_path == null) { throw new InvalidOperationException(); } - WriteFile(CombinePath(_path, Challenge.HttpResourcePath), Challenge.HttpResourceValue); - _challengeWritten = true; + var path = CombinePath(_path, challenge.HttpResourcePath); + WriteFile(path, challenge.HttpResourceValue); + if (!_filesWritten.Contains(path)) + { + _filesWritten.Add(path); + } } /// <summary> @@ -163,7 +176,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// <param name="target"></param> /// <param name="answerPath"></param> /// <param name="token"></param> - private void WriteWebConfig() + private void WriteWebConfig(Http01ChallengeValidationDetails challenge) { if (_path == null) { @@ -173,13 +186,20 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins { try { - _log.Debug("Writing web.config"); - var partialPath = Challenge.HttpResourcePath.Split('/').Last(); - var destination = CombinePath(_path, Challenge.HttpResourcePath.Replace(partialPath, "web.config")); - var content = GetWebConfig(); - WriteFile(destination, content); - _webConfigWritten = true; - } + var partialPath = challenge.HttpResourcePath.Split('/').Last(); + var destination = CombinePath(_path, challenge.HttpResourcePath.Replace(partialPath, "web.config")); + if (!_filesWritten.Contains(destination)) + { + var content = GetWebConfig().Value; + if (content != null) + { + _log.Debug("Writing web.config"); + WriteFile(destination, content); + _filesWritten.Add(destination); + } + + } + } catch (Exception ex) { _log.Warning("Unable to write web.config: {ex}", ex.Message); ; @@ -191,66 +211,16 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// Get the template for the web.config /// </summary> /// <returns></returns> - private string GetWebConfig() => File.ReadAllText(TemplateWebConfig); - - /// <summary> - /// Can be used to write out server specific configuration, to handle extensionless files etc. - /// </summary> - /// <param name="target"></param> - /// <param name="answerPath"></param> - /// <param name="token"></param> - private void DeleteWebConfig() - { - if (_path == null) - { - throw new InvalidOperationException(); - } - if (_webConfigWritten) - { - _log.Debug("Deleting web.config"); - var partialPath = Challenge.HttpResourcePath.Split('/').Last(); - var destination = CombinePath(_path, Challenge.HttpResourcePath.Replace(partialPath, "web.config")); - DeleteFile(destination); - } - } - - /// <summary> - /// Should delete any authorizations - /// </summary> - /// <param name="answerPath">where the answerFile should be located</param> - /// <param name="token">the token</param> - /// <param name="webRootPath">the website root path</param> - /// <param name="filePath">the file path for the authorization file</param> - private void DeleteAuthorization() - { + private Lazy<string?> GetWebConfig() => new Lazy<string?>(() => { try { - if (_path != null && _challengeWritten) - { - _log.Debug("Deleting answer"); - var path = CombinePath(_path, Challenge.HttpResourcePath); - var partialPath = Challenge.HttpResourcePath.Split('/').Last(); - DeleteFile(path); - if (_settings.Validation.CleanupFolders) - { - path = path.Replace($"{PathSeparator}{partialPath}", ""); - if (DeleteFolderIfEmpty(path)) - { - var idx = path.LastIndexOf(PathSeparator); - if (idx >= 0) - { - path = path.Substring(0, path.LastIndexOf(PathSeparator)); - DeleteFolderIfEmpty(path); - } - } - } - } - } - catch (Exception ex) + return File.ReadAllText(TemplateWebConfig); + } + catch { - _log.Warning("Error occured while deleting folder structure. Error: {@ex}", ex); + return null; } - } + }); /// <summary> /// Combine root path with relative path @@ -271,11 +241,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// </summary> /// <param name="path"></param> /// <returns></returns> - private bool DeleteFolderIfEmpty(string path) + private async Task<bool> DeleteFolderIfEmpty(string path) { - if (IsEmpty(path)) + if (await IsEmpty(path)) { - DeleteFolder(path); + await DeleteFolder(path); return true; } else @@ -291,44 +261,78 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// <param name="root"></param> /// <param name="path"></param> /// <param name="content"></param> - protected abstract void WriteFile(string path, string content); + protected abstract Task WriteFile(string path, string content); /// <summary> /// Delete file from specific location /// </summary> /// <param name="root"></param> /// <param name="path"></param> - protected abstract void DeleteFile(string path); + protected abstract Task DeleteFile(string path); /// <summary> /// Check if folder is empty /// </summary> /// <param name="root"></param> /// <param name="path"></param> - protected abstract bool IsEmpty(string path); + protected abstract Task<bool> IsEmpty(string path); /// <summary> /// Delete folder if not empty /// </summary> /// <param name="root"></param> /// <param name="path"></param> - protected abstract void DeleteFolder(string path); + protected abstract Task DeleteFolder(string path); /// <summary> /// Refresh /// </summary> /// <param name="scheduled"></param> /// <returns></returns> - protected virtual void Refresh() { } + protected virtual void Refresh(TargetPart targetPart) { } /// <summary> /// Dispose /// </summary> - public override Task CleanUp() + public override async Task CleanUp() { - DeleteWebConfig(); - DeleteAuthorization(); - return Task.CompletedTask; + try + { + if (_path != null) + { + var folders = new List<string>(); + foreach (var file in _filesWritten) + { + _log.Debug("Deleting files"); + await DeleteFile(file); + var folder = file.Substring(0, file.LastIndexOf(PathSeparator)); + if (!folders.Contains(folder)) + { + folders.Add(folder); + } + } + if (_settings.Validation.CleanupFolders) + { + _log.Debug("Deleting empty folders"); + foreach (var folder in folders) + { + if (await DeleteFolderIfEmpty(folder)) + { + var idx = folder.LastIndexOf(PathSeparator); + if (idx >= 0) + { + var parent = folder.Substring(0, folder.LastIndexOf(PathSeparator)); + await DeleteFolderIfEmpty(parent); + } + } + } + } + } + } + catch (Exception ex) + { + _log.Warning("Error occured while deleting folder structure. Error: {@ex}", ex); + } } } } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationParameters.cs b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationParameters.cs index b1958b4..7092cdd 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationParameters.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationParameters.cs @@ -7,9 +7,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins { public ISettingsService Settings { get; private set; } public Renewal Renewal { get; private set; } - public TargetPart TargetPart { get; private set; } public RunLevel RunLevel { get; private set; } - public string Identifier { get; private set; } public ILogService LogService { get; private set; } public IInputService InputService { get; private set; } public ProxyService ProxyService { get; private set; } @@ -20,14 +18,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins ISettingsService settings, ProxyService proxy, Renewal renewal, - TargetPart target, - RunLevel runLevel, - string identifier) + RunLevel runLevel) { Renewal = renewal; - TargetPart = target; RunLevel = runLevel; - Identifier = identifier; Settings = settings; ProxyService = proxy; LogService = log; diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs index 6e165af..c81966f 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs @@ -1,7 +1,9 @@ using ACMESharp.Authorizations; +using PKISharp.WACS.Context; +using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.Net; using System.Threading.Tasks; @@ -13,12 +15,18 @@ 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.Prepare; + private bool HasListener => _listener != null; private HttpListener Listener { @@ -37,11 +45,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) { @@ -61,42 +69,61 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http } } - public override Task CleanUp() + public override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) + { + // Add validation file + _files.GetOrAdd("/" + challenge.HttpResourcePath, challenge.HttpResourceValue); + return Task.CompletedTask; + } + + public override Task Commit() { - if (HasListener) + // Create listener if it doesn't exist yet + lock (_listenerLock) { - try - { - Listener.Stop(); - Listener.Close(); - } - catch + 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; + } } } return Task.CompletedTask; } - public override Task PrepareChallenge() + public override Task CleanUp() { - _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 + // Cleanup listener if nobody else has done it 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 (HasListener) + { + try + { + Listener.Stop(); + Listener.Close(); + } + finally + { + _listener = null; + } + } } + return Task.CompletedTask; } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/Sftp/Sftp.cs b/src/main.lib/Plugins/ValidationPlugins/Http/Sftp/Sftp.cs index c3d5c08..a7a88ee 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/Sftp/Sftp.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/Sftp/Sftp.cs @@ -1,5 +1,6 @@ using PKISharp.WACS.Clients; using System.Linq; +using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins.Http { @@ -11,12 +12,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http protected override char PathSeparator => '/'; - protected override void DeleteFile(string path) => _sshFtpClient.Delete(path, SshFtpClient.FileType.File); + protected override async Task DeleteFile(string path) => _sshFtpClient.Delete(path, SshFtpClient.FileType.File); - protected override void DeleteFolder(string path) => _sshFtpClient.Delete(path, SshFtpClient.FileType.Directory); + protected override async Task DeleteFolder(string path) => _sshFtpClient.Delete(path, SshFtpClient.FileType.Directory); - protected override bool IsEmpty(string path) => !_sshFtpClient.GetFiles(path).Any(); + protected override async Task<bool> IsEmpty(string path) => !_sshFtpClient.GetFiles(path).Any(); - protected override void WriteFile(string path, string content) => _sshFtpClient.Upload(path, content); + protected override async Task WriteFile(string path, string content) => _sshFtpClient.Upload(path, content); } } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs index 990f338..f1adaea 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs @@ -1,4 +1,6 @@ -using PKISharp.WACS.Client; +using ACMESharp.Authorizations; +using PKISharp.WACS.Client; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System.Threading.Tasks; @@ -13,15 +15,15 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http RunLevel runLevel, ProxyService proxy) : base(options, runLevel, pars) => _webdavClient = new WebDavClientWrapper(_options.Credential, pars.LogService, proxy); - protected override void DeleteFile(string path) => _webdavClient.Delete(path); + protected override async Task DeleteFile(string path) => _webdavClient.Delete(path); - protected override void DeleteFolder(string path) => _webdavClient.Delete(path); + protected override async Task DeleteFolder(string path) => _webdavClient.Delete(path); - protected override bool IsEmpty(string path) => !_webdavClient.IsEmpty(path); + protected override async Task<bool> IsEmpty(string path) => !_webdavClient.IsEmpty(path); protected override char PathSeparator => '/'; - protected override void WriteFile(string path, string content) => _webdavClient.Upload(path, content); + protected override async Task WriteFile(string path, string content) => _webdavClient.Upload(path, content); public override Task CleanUp() { base.CleanUp(); diff --git a/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs index d43d58b..f7cea99 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHosting.cs @@ -1,5 +1,8 @@ using ACMESharp.Authorizations; using Org.BouncyCastle.Asn1; +using PKISharp.WACS.Context; +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; using System.Collections.Generic; @@ -20,7 +23,6 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls internal const int DefaultValidationPort = 443; private TcpListener? _listener; private X509Certificate2? _certificate; - private readonly string _identifier; private readonly SelfHostingOptions _options; private readonly ILogService _log; private readonly IUserRoleService _userRoleService; @@ -39,9 +41,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls set => _listener = value; } - public SelfHosting(ILogService log, string identifier, SelfHostingOptions options, IUserRoleService userRoleService) + public SelfHosting(ILogService log, SelfHostingOptions options, IUserRoleService userRoleService) { - _identifier = identifier; _log = log; _options = options; _userRoleService = userRoleService; @@ -69,6 +70,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls } } + public override Task Commit() => Task.CompletedTask; + public override Task CleanUp() { try @@ -82,12 +85,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls return Task.CompletedTask; } - public override Task PrepareChallenge() + public override Task PrepareChallenge(ValidationContext context, TlsAlpn01ChallengeValidationDetails challenge) { try { using var rsa = RSA.Create(2048); - var name = new X500DistinguishedName($"CN={_identifier}"); + var name = new X500DistinguishedName($"CN={context.Identifier}"); var request = new CertificateRequest( name, @@ -96,7 +99,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls RSASignaturePadding.Pkcs1); using var sha = SHA256.Create(); - var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(Challenge.TokenValue)); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(challenge.TokenValue)); request.CertificateExtensions.Add( new X509Extension( new AsnEncodedData("1.3.6.1.5.5.7.1.31", @@ -104,7 +107,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls true)); var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName(_identifier); + sanBuilder.AddDnsName(context.Identifier); request.CertificateExtensions.Add(sanBuilder.Build()); _certificate = request.CreateSelfSigned( @@ -112,8 +115,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls new DateTimeOffset(DateTime.UtcNow.AddDays(1))); _certificate = new X509Certificate2( - _certificate.Export(X509ContentType.Pfx, _identifier), - _identifier, + _certificate.Export(X509ContentType.Pfx, context.Identifier), + context.Identifier, X509KeyStorageFlags.MachineKeySet); _listener = new TcpListener(IPAddress.Any, _options.Port ?? DefaultValidationPort); diff --git a/src/main.lib/Plugins/ValidationPlugins/Validation.cs b/src/main.lib/Plugins/ValidationPlugins/Validation.cs index cb9da68..d846e62 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; @@ -10,31 +11,15 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// </summary> public abstract class Validation<TChallenge> : IValidationPlugin where TChallenge : class, IChallengeValidationDetails { - public bool HasChallenge => _challenge != null; - public TChallenge Challenge - { - get - { - if (_challenge == null) - { - throw new InvalidOperationException(); - } - return _challenge; - } - } - private TChallenge? _challenge; - - /// <summary> /// Handle the challenge /// </summary> /// <param name="challenge"></param> - public async Task PrepareChallenge(IChallengeValidationDetails challenge) + public async Task PrepareChallenge(ValidationContext context) { - if (challenge is TChallenge typed) + if (context.ChallengeDetails is TChallenge typed) { - _challenge = typed; - await PrepareChallenge(); + await PrepareChallenge(context, typed); } else { @@ -46,16 +31,24 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins /// Handle the challenge /// </summary> /// <param name="challenge"></param> - public abstract Task PrepareChallenge(); + public abstract Task PrepareChallenge(ValidationContext context, TChallenge typed); /// <summary> - /// Clean up after validation + /// Commit changes /// </summary> + /// <returns></returns> + public abstract Task Commit(); + public abstract Task CleanUp(); /// <summary> /// 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/RenewalCreator.cs b/src/main.lib/RenewalCreator.cs index 404ee76..0210ca2 100644 --- a/src/main.lib/RenewalCreator.cs +++ b/src/main.lib/RenewalCreator.cs @@ -363,14 +363,14 @@ namespace PKISharp.WACS { goto retry; } - _exceptionHandler.HandleException(message: $"Create certificate failed: {string.Join(", ", result.ErrorMessages)}"); + _exceptionHandler.HandleException(message: $"Create certificate failed: {string.Join("\n\t- ", result.ErrorMessages)}"); } else { try { _renewalStore.Save(renewal, result); - _notification.NotifyCreated(renewal, _log.Lines); + await _notification.NotifyCreated(renewal, _log.Lines); } catch (Exception ex) { diff --git a/src/main.lib/RenewalExecutor.cs b/src/main.lib/RenewalExecutor.cs index aa38dca..5f17bef 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; @@ -10,7 +12,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using acme = ACMESharp.Protocol.Resources; namespace PKISharp.WACS { @@ -25,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; @@ -189,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, runLevel); if (context.Result.Success) { // Execute final steps (CSR, store, install) @@ -200,54 +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 - await HandleChallenge(context, targetPart, authorization); - if (!context.Result.Success) - { - break; - } - } - } - - /// <summary> /// Steps to take on succesful (re)authorization /// </summary> /// <param name="partialTarget"></param> @@ -395,156 +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 HandleChallenge(ExecutionContext context, TargetPart targetPart, acme.Authorization authorization) - { - var valid = false; - var client = context.Scope.Resolve<AcmeClient>(); - var identifier = authorization.Identifier.Value; - var options = context.Renewal.ValidationPluginOptions; - IValidationPlugin? validationPlugin = null; - using var validation = _scopeBuilder.Validation(context.Scope, options, targetPart, identifier); - try - { - if (authorization.Status == AcmeClient.AuthorizationValid) - { - _log.Information("Cached authorization result for {identifier}: {Status}", identifier, authorization.Status); - if (!context.RunLevel.HasFlag(RunLevel.Test) && - !context.RunLevel.HasFlag(RunLevel.IgnoreCache)) - { - return; - } - // Used to make --force or --test re-validation errors non-fatal - _log.Information("Handling challenge anyway because --test and/or --force is active"); - valid = true; - } - - _log.Information("Authorize identifier {identifier}", identifier); - _log.Verbose("Initial authorization status: {status}", authorization.Status); - _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 (valid) - { - var usedType = authorization.Challenges. - Where(x => x.Status == AcmeClient.ChallengeValid). - FirstOrDefault(); - _log.Warning("Expected challenge type {type} not available for {identifier}, already validated using {valided}.", - options.ChallengeType, - authorization.Identifier.Value, - usedType?.Type ?? "[unknown]"); - return; - } - else - { - _log.Error("Expected challenge type {type} not available for {identifier}.", - options.ChallengeType, - authorization.Identifier.Value); - context.Result.AddErrorMessage("Expected challenge type not available", !valid); - return; - } - } - else - { - _log.Verbose("Initial challenge status: {status}", 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 (!context.RunLevel.HasFlag(RunLevel.Test) && - !context.RunLevel.HasFlag(RunLevel.IgnoreCache)) - { - _log.Information("Cached challenge result: {Status}", authorization.Status); - return; - } - } - } - - // We actually have to do validation now - try - { - validationPlugin = validation.Resolve<IValidationPlugin>(); - } - catch (Exception ex) - { - _log.Error(ex, "Error resolving validation plugin"); - } - if (validationPlugin == null) - { - _log.Error("Validation plugin not found or not created"); - context.Result.AddErrorMessage("Validation plugin not found or not created", !valid); - return; - } - var (disabled, disabledReason) = validationPlugin.Disabled; - if (disabled) - { - _log.Error($"Validation plugin is not available. {disabledReason}"); - context.Result.AddErrorMessage("Validation plugin is not available", !valid); - return; - } - _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"); - context.Result.AddErrorMessage("Error preparing for challenge answer", !valid); - return; - } - - _log.Debug("Submitting challenge answer"); - challenge = await client.AnswerChallenge(challenge); - if (challenge.Status != AcmeClient.ChallengeValid) - { - if (challenge.Error != null) - { - _log.Error(challenge.Error.ToString()); - } - _log.Error("Authorization result: {Status}", challenge.Status); - context.Result.AddErrorMessage(challenge.Error?.ToString() ?? "Unspecified error", !valid); - return; - } - else - { - _log.Information("Authorization result: {Status}", challenge.Status); - return; - } - } - catch (Exception ex) - { - _log.Error("Error authorizing {renewal}", targetPart); - var message = _exceptionHandler.HandleException(ex); - context.Result.AddErrorMessage(message, !valid); - } - 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); - } - } - } - } + } } diff --git a/src/main.lib/RenewalManager.cs b/src/main.lib/RenewalManager.cs index 93ef1df..b4a4aff 100644 --- a/src/main.lib/RenewalManager.cs +++ b/src/main.lib/RenewalManager.cs @@ -400,7 +400,7 @@ namespace PKISharp.WACS _input.Show(null, "Please input friendly name to filter renewals by. " + IISArgumentsProvider.PatternExamples); var rawInput = await _input.RequestString("Friendly name"); var ret = new List<Renewal>(); - var regex = new Regex(rawInput.PatternToRegex()); + var regex = new Regex(rawInput.PatternToRegex(), RegexOptions.IgnoreCase); foreach (var r in current) { if (regex.Match(r.LastFriendlyName).Success) @@ -494,18 +494,18 @@ namespace PKISharp.WACS _renewalStore.Save(renewal, result); if (result.Success) { - notification.NotifySuccess(renewal, _log.Lines); + await notification.NotifySuccess(renewal, _log.Lines); } else { - notification.NotifyFailure(runLevel, renewal, result.ErrorMessages, _log.Lines); + await notification.NotifyFailure(runLevel, renewal, result.ErrorMessages, _log.Lines); } } } catch (Exception ex) { _exceptionHandler.HandleException(ex); - notification.NotifyFailure(runLevel, renewal, new List<string> { ex.Message }, _log.Lines); + await notification.NotifyFailure(runLevel, renewal, new List<string> { ex.Message }, _log.Lines); } } @@ -633,4 +633,4 @@ namespace PKISharp.WACS #endregion } -}
\ No newline at end of file +} diff --git a/src/main.lib/RenewalValidator.cs b/src/main.lib/RenewalValidator.cs new file mode 100644 index 0000000..3421d07 --- /dev/null +++ b/src/main.lib/RenewalValidator.cs @@ -0,0 +1,447 @@ +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.Security.Policy; +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, 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<IValidationPlugin>(); + 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<ValidationContextParameters>().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); + } + } + + /// <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(); + var plugin = contexts.First().ValidationPlugin; + + // 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; + } + } + } + + // Cleanup + await CleanValidation(contexts.First().ValidationPlugin); + } + } + + /// <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, bool orderValid) + { + // Get authorization challenge details from server + var client = context.Scope.Resolve<AcmeClient>(); + 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); + } + + /// <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 != true)); + 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.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.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) + { + _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 == false); + 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 == false); + } + } + + /// <summary> + /// Clean up after (succesful or unsuccesful) validation attempt + /// </summary> + /// <param name="validationContext"></param> + /// <returns></returns> + private async Task<bool> 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; + } + } + + /// <summary> + /// Clean up after (succesful or unsuccesful) validation attempt + /// </summary> + /// <param name="validationContext"></param> + /// <returns></returns> + 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); + } + } + } +} diff --git a/src/main.lib/Services/AutofacBuilder.cs b/src/main.lib/Services/AutofacBuilder.cs index 5a5e4f9..00080fd 100644 --- a/src/main.lib/Services/AutofacBuilder.cs +++ b/src/main.lib/Services/AutofacBuilder.cs @@ -202,19 +202,12 @@ namespace PKISharp.WACS.Services /// <param name="target"></param> /// <param name="identifier"></param> /// <returns></returns> - public ILifetimeScope Validation(ILifetimeScope execution, ValidationPluginOptions options, TargetPart target, string identifier) + public ILifetimeScope Validation(ILifetimeScope execution, ValidationPluginOptions options) { return execution.BeginLifetimeScope(builder => { - builder.RegisterType<HttpValidationParameters>(). - WithParameters(new[] { - new TypedParameter(typeof(string), identifier), - new TypedParameter(typeof(TargetPart), target) - }); + builder.RegisterType<HttpValidationParameters>(); builder.RegisterType(options.Instance). - WithParameters(new[] { - new TypedParameter(typeof(string), identifier), - }). As<IValidationPlugin>(). SingleInstance(); }); diff --git a/src/main.lib/Services/Interfaces/IAutofacBuilder.cs b/src/main.lib/Services/Interfaces/IAutofacBuilder.cs index c2a3893..eb11a39 100644 --- a/src/main.lib/Services/Interfaces/IAutofacBuilder.cs +++ b/src/main.lib/Services/Interfaces/IAutofacBuilder.cs @@ -50,6 +50,6 @@ namespace PKISharp.WACS.Services /// <param name="target"></param> /// <param name="identifier"></param> /// <returns></returns> - ILifetimeScope Validation(ILifetimeScope execution, ValidationPluginOptions options, TargetPart target, string identifier); + ILifetimeScope Validation(ILifetimeScope execution, ValidationPluginOptions options); } } diff --git a/src/main.lib/Services/NotificationService.cs b/src/main.lib/Services/NotificationService.cs index cff3803..7795fac 100644 --- a/src/main.lib/Services/NotificationService.cs +++ b/src/main.lib/Services/NotificationService.cs @@ -5,6 +5,7 @@ using Serilog.Events; using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using System.Web; namespace PKISharp.WACS.Services @@ -33,7 +34,7 @@ namespace PKISharp.WACS.Services /// </summary> /// <param name="runLevel"></param> /// <param name="renewal"></param> - internal void NotifyCreated(Renewal renewal, IEnumerable<MemoryEntry> log) + internal async Task NotifyCreated(Renewal renewal, IEnumerable<MemoryEntry> log) { // Do not send emails when running interactively _log.Information( @@ -42,7 +43,7 @@ namespace PKISharp.WACS.Services renewal.LastFriendlyName); if (_settings.Notification.EmailOnSuccess) { - _email.Send( + await _email.Send( $"Certificate {renewal.LastFriendlyName} created", @$"<p>Certificate <b>{HttpUtility.HtmlEncode(renewal.LastFriendlyName)}</b> succesfully created.</p> {NotificationInformation(renewal)} @@ -56,7 +57,7 @@ namespace PKISharp.WACS.Services /// </summary> /// <param name="runLevel"></param> /// <param name="renewal"></param> - internal void NotifySuccess(Renewal renewal, IEnumerable<MemoryEntry> log) + internal async Task NotifySuccess(Renewal renewal, IEnumerable<MemoryEntry> log) { // Do not send emails when running interactively _log.Information( @@ -65,7 +66,7 @@ namespace PKISharp.WACS.Services renewal.LastFriendlyName); if (_settings.Notification.EmailOnSuccess) { - _email.Send( + await _email.Send( $"Certificate renewal {renewal.LastFriendlyName} completed", @$"<p>Certificate <b>{HttpUtility.HtmlEncode(renewal.LastFriendlyName)}</b> succesfully renewed.</p> {NotificationInformation(renewal)} @@ -79,7 +80,7 @@ namespace PKISharp.WACS.Services /// </summary> /// <param name="runLevel"></param> /// <param name="renewal"></param> - internal void NotifyFailure( + internal async Task NotifyFailure( RunLevel runLevel, Renewal renewal, List<string> errors, @@ -93,7 +94,7 @@ namespace PKISharp.WACS.Services } if (runLevel.HasFlag(RunLevel.Unattended)) { - _email.Send( + await _email.Send( $"Error processing certificate renewal {renewal.LastFriendlyName}", @$"<p>Renewal for <b>{HttpUtility.HtmlEncode(renewal.LastFriendlyName)}</b> failed, will retry on next run.<br><br>Error(s): <ul><li>{string.Join("</li><li>", errors.Select(x => HttpUtility.HtmlEncode(x)))}</li></ul></p> diff --git a/src/main.lib/Services/PluginService.cs b/src/main.lib/Services/PluginService.cs index 023f0f0..af06227 100644 --- a/src/main.lib/Services/PluginService.cs +++ b/src/main.lib/Services/PluginService.cs @@ -107,9 +107,9 @@ namespace PKISharp.WACS.Services { return x.AsType(); } - catch (Exception) + catch (Exception ex) { - _log.Error("Error loading type {x}", x.FullName); + _log.Error(ex, "Error loading type {x}", x.FullName); throw; } } 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.lib/Services/TaskSchedulerService.cs b/src/main.lib/Services/TaskSchedulerService.cs index 37f3c0f..28ccf81 100644 --- a/src/main.lib/Services/TaskSchedulerService.cs +++ b/src/main.lib/Services/TaskSchedulerService.cs @@ -65,11 +65,11 @@ namespace PKISharp.WACS.Services { var healthy = true; if (!task.Definition.Actions.OfType<ExecAction>().Any(action => - action.Path == _settings.ExePath && - action.WorkingDirectory == WorkingDirectory)) + string.Equals(action.Path, _settings.ExePath, StringComparison.OrdinalIgnoreCase) && + string.Equals(action.WorkingDirectory, WorkingDirectory, StringComparison.OrdinalIgnoreCase))) { healthy = false; - _log.Warning("Scheduled task points to different location"); + _log.Warning("Scheduled task points to different location for .exe and/or working directory"); } if (!task.Enabled) { diff --git a/src/main.test/Mock/MockContainer.cs b/src/main.test/Mock/MockContainer.cs index 1e06ebb..76560ae 100644 --- a/src/main.test/Mock/MockContainer.cs +++ b/src/main.test/Mock/MockContainer.cs @@ -57,6 +57,7 @@ namespace PKISharp.WACS.UnitTests.Mock _ = builder.RegisterType<mock.CertificateService>().As<real.ICertificateService>().SingleInstance(); _ = builder.RegisterType<real.TaskSchedulerService>().SingleInstance(); _ = builder.RegisterType<real.NotificationService>().SingleInstance(); + _ = builder.RegisterType<RenewalValidator>().SingleInstance(); _ = builder.RegisterType<RenewalExecutor>().SingleInstance(); _ = builder.RegisterType<RenewalManager>().SingleInstance(); _ = builder.Register(c => c.Resolve<real.IArgumentsService>().MainArguments).SingleInstance(); diff --git a/src/main.test/Tests/BindingTests/Bindings.cs b/src/main.test/Tests/BindingTests/Bindings.cs index 8b4a57a..17c886d 100644 --- a/src/main.test/Tests/BindingTests/Bindings.cs +++ b/src/main.test/Tests/BindingTests/Bindings.cs @@ -882,6 +882,41 @@ namespace PKISharp.WACS.UnitTests.Tests.BindingTests Assert.AreEqual(oldCert1, updatedBinding.CertificateHash); } + /// <summary> + /// Like above, but SNI cannot be turned on for the default + /// website / empty host. The code should ignore the change. + /// </summary> + [TestMethod] + public void CentralSSLTrap() + { + var iis = new MockIISClient(log) + { + MockSites = new[] { + new MockSite() { + Id = 1, + Bindings = new List<MockBinding> { + new MockBinding() { + IP = DefaultIP, + Port = DefaultPort, + Host = "", + Protocol = "http" + } + } + } + } + }; + + var bindingOptions = new BindingOptions(). + WithSiteId(1). + WithIP(DefaultIP). + WithPort(DefaultPort). + WithFlags(SSLFlags.CentralSsl); + + iis.AddOrUpdateBindings(new[] { "mail.example.com" }, bindingOptions, null); + + Assert.IsTrue(true); + } + [TestMethod] public void DuplicateBinding() { diff --git a/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs b/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs index 82b805a..6c17610 100644 --- a/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs +++ b/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs @@ -22,8 +22,11 @@ namespace PKISharp.WACS.UnitTests.Tests.RenewalTests { var container = new MockContainer().TestScope(); var renewalStore = container.Resolve<real.IRenewalStore>(); + var renewalValidator = container.Resolve<RenewalValidator>( + new TypedParameter(typeof(IContainer), container)); var renewalExecutor = container.Resolve<RenewalExecutor>( - new TypedParameter(typeof(IContainer), container)); + new TypedParameter(typeof(RenewalValidator), renewalValidator), + new TypedParameter(typeof(IContainer), container)); var renewalManager = container.Resolve<RenewalManager>( new TypedParameter(typeof(IContainer), container), new TypedParameter(typeof(RenewalExecutor), renewalExecutor)); 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..d40bd2d 100644 --- a/src/main/settings.json +++ b/src/main/settings.json @@ -61,6 +61,7 @@ "Validation": { "DefaultValidation": null, "DefaultValidationMode": null, + "DisableMultiThreading": true, "CleanupFolders": true, "PreValidateDns": true, "PreValidateDnsRetryCount": 5, @@ -94,4 +95,4 @@ "Installation": { "DefaultInstallation": null } -}
\ No newline at end of file +} diff --git a/src/plugin.validation.dns.azure/Azure.cs b/src/plugin.validation.dns.azure/Azure.cs index 2d8c1e0..2a0c741 100755 --- a/src/plugin.validation.dns.azure/Azure.cs +++ b/src/plugin.validation.dns.azure/Azure.cs @@ -4,68 +4,148 @@ 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.Services; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; - namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { + /// <summary> + /// Handle creation of DNS records in Azure + /// </summary> internal class Azure : DnsValidation<Azure> { private DnsManagementClient _azureDnsClient; - private readonly DomainParseService _domainParser; private readonly ProxyService _proxyService; - private readonly AzureOptions _options; + private readonly Dictionary<string, Dictionary<string, RecordSet>> _recordSets; + private IEnumerable<Zone> _hostedZones; + public Azure(AzureOptions options, - DomainParseService domainParser, LookupClientProvider dnsClient, ProxyService proxyService, ILogService log, - ISettingsService settings) - : base(dnsClient, log, settings) + ISettingsService settings) : base(dnsClient, log, settings) { _options = options; - _domainParser = domainParser; _proxyService = proxyService; + _recordSets = new Dictionary<string, Dictionary<string, RecordSet>>(); } - public override async Task<bool> CreateRecord(string recordName, string token) + /// <summary> + /// Allow this plugin to process multiple validations at the same time. + /// They will still be prepared and cleaned in serial order though not + /// to overwhelm the DnsManagementClient or risk threads overwriting + /// eachothers changes. + /// </summary> + public override ParallelOperations Parallelism => ParallelOperations.Answer; + + /// <summary> + /// Create record in Azure DNS + /// </summary> + /// <param name="context"></param> + /// <param name="recordName"></param> + /// <param name="token"></param> + /// <returns></returns> + public override async Task<bool> CreateRecord(DnsValidationRecord record) { - var client = await GetClient(); - var zone = await GetHostedZone(recordName); + var zone = await GetHostedZone(record.Authority.Domain); if (zone == null) { return false; } + // Create or update record set parameters + var txtRecord = new TxtRecord(new[] { record.Value }); + if (!_recordSets.ContainsKey(zone)) + { + _recordSets.Add(zone, new Dictionary<string, RecordSet>()); + } + var zoneRecords = _recordSets[zone]; + var relativeKey = RelativeRecordName(zone, record.Authority.Domain); + if (!zoneRecords.ContainsKey(relativeKey)) + { + zoneRecords.Add( + relativeKey, + new RecordSet + { + TTL = 0, + TxtRecords = new List<TxtRecord> { txtRecord } + }); + } + else + { + zoneRecords[relativeKey].TxtRecords.Add(txtRecord); + } + return true; + } - // Create record set parameters - var recordSetParams = new RecordSet + /// <summary> + /// Send all buffered changes to Azure + /// </summary> + /// <returns></returns> + public override async Task SaveChanges() + { + var updateTasks = new List<Task>(); + foreach (var zone in _recordSets.Keys) { - TTL = 0, - TxtRecords = new List<TxtRecord> + foreach (var domain in _recordSets[zone].Keys) { - new TxtRecord(new[] { token }) + updateTasks.Add(CreateOrUpdateRecordSet(zone, domain)); } - }; + } + await Task.WhenAll(updateTasks); + } + /// <summary> + /// Store a single recordset + /// </summary> + /// <param name="zone"></param> + /// <param name="domain"></param> + /// <param name="recordSet"></param> + /// <returns></returns> + private async Task CreateOrUpdateRecordSet(string zone, string domain) + { try { - _ = await client.RecordSets.CreateOrUpdateAsync(_options.ResourceGroupName, - zone, - RelativeRecordName(zone, recordName), - RecordType.TXT, - recordSetParams); + var newSet = _recordSets[zone][domain]; + var client = await GetClient(); + try + { + var originalSet = await client.RecordSets.GetAsync(_options.ResourceGroupName, + zone, + domain, + RecordType.TXT); + _recordSets[zone][domain] = originalSet; + } + catch + { + _recordSets[zone][domain] = null; + } + if (newSet == null) + { + await client.RecordSets.DeleteAsync( + _options.ResourceGroupName, + zone, + domain, + RecordType.TXT); + } + else + { + _ = await client.RecordSets.CreateOrUpdateAsync( + _options.ResourceGroupName, + zone, + domain, + RecordType.TXT, + newSet); + } } catch (Exception ex) { - _log.Error(ex, "Error updating record in Azure"); - return false; + _log.Error(ex, "Error updating DNS records in {zone} ({domain})", zone, domain); } - return true; } private async Task<DnsManagementClient> GetClient() @@ -97,26 +177,42 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } return _azureDnsClient; } + + /// <summary> + /// Translate full host name to zone relative name + /// </summary> + /// <param name="zone"></param> + /// <param name="recordName"></param> + /// <returns></returns> private string RelativeRecordName(string zone, string recordName) { var ret = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.'); return string.IsNullOrEmpty(ret) ? "@" : ret; } - + /// <summary> + /// Find the approriate hosting zone to use for record updates + /// </summary> + /// <param name="recordName"></param> + /// <returns></returns> private async Task<string> GetHostedZone(string recordName) { - var client = await GetClient(); - var zones = new List<Zone>(); - var response = await client.Zones.ListByResourceGroupAsync(_options.ResourceGroupName); - zones.AddRange(response); - while (!string.IsNullOrEmpty(response.NextPageLink)) + // Cache so we don't have to repeat this more than once for each renewal + if (_hostedZones == null) { - response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink); + var client = await GetClient(); + var zones = new List<Zone>(); + var response = await client.Zones.ListByResourceGroupAsync(_options.ResourceGroupName); + zones.AddRange(response); + while (!string.IsNullOrEmpty(response.NextPageLink)) + { + response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink); + } + _log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones.Count, _options.ResourceGroupName); + _hostedZones = zones; } - _log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones, _options.ResourceGroupName); - var hostedZone = FindBestMatch(zones.ToDictionary(x => x.Name), recordName); + var hostedZone = FindBestMatch(_hostedZones.ToDictionary(x => x.Name), recordName); if (hostedZone != null) { return hostedZone.Name; @@ -128,22 +224,19 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns return null; } - public override async Task DeleteRecord(string recordName, string token) - { - var client = await GetClient(); - var zone = await GetHostedZone(recordName); - try - { - await client.RecordSets.DeleteAsync( - _options.ResourceGroupName, - zone, - RelativeRecordName(zone, recordName), - RecordType.TXT); - } - catch (Exception ex) - { - _log.Error(ex, "Error deleting record from Azure"); - } - } + /// <summary> + /// Ignored because we keep track of our list of changes + /// </summary> + /// <param name="record"></param> + /// <returns></returns> + public override Task DeleteRecord(DnsValidationRecord record) => Task.CompletedTask; + + /// <summary> + /// Clear created createds + /// </summary> + /// <returns></returns> + public override async Task Finalize() => + // We save the original record sets, so this should restore them + await SaveChanges(); } } diff --git a/src/plugin.validation.dns.cloudflare/Cloudflare.cs b/src/plugin.validation.dns.cloudflare/Cloudflare.cs index e770735..98c8cbf 100644 --- a/src/plugin.validation.dns.cloudflare/Cloudflare.cs +++ b/src/plugin.validation.dns.cloudflare/Cloudflare.cs @@ -3,6 +3,7 @@ using FluentCloudflare.Api; using FluentCloudflare.Api.Entities; using FluentCloudflare.Extensions; using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Linq; @@ -23,8 +24,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns ProxyService proxyService, LookupClientProvider dnsClient, ILogService log, - ISettingsService settings) - : base(dnsClient, log, settings) + ISettingsService settings) : base(dnsClient, log, settings) { _options = options; _hc = proxyService.GetHttpClient(); @@ -54,10 +54,10 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns return zonesResp.Unpack().First(); } - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { var ctx = GetContext(); - var zone = await GetHostedZone(ctx, recordName).ConfigureAwait(false); + var zone = await GetHostedZone(ctx, record.Authority.Domain).ConfigureAwait(false); if (zone == null) { _log.Error("The zone could not be found using the Cloudflare API, thus creating a DNS validation record is impossible. " + @@ -68,7 +68,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } var dns = ctx.Zone(zone).Dns; - _ = await dns.Create(DnsRecordType.TXT, recordName, token) + _ = await dns.Create(DnsRecordType.TXT, record.Authority.Domain, record.Value) .CallAsync(_hc) .ConfigureAwait(false); return true; @@ -105,11 +105,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { var ctx = GetContext(); - var zone = await GetHostedZone(ctx, recordName).ConfigureAwait(false); - await DeleteRecord(recordName, token, ctx, zone); + var zone = await GetHostedZone(ctx, record.Authority.Domain).ConfigureAwait(false); + await DeleteRecord(record.Authority.Domain, record.Value, ctx, zone); } public void Dispose() => _hc.Dispose(); diff --git a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs index 2e6e5db..2638d63 100644 --- a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs +++ b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs @@ -1,4 +1,5 @@ using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Plugins.ValidationPlugins.Dreamhost; using PKISharp.WACS.Services; using System; @@ -18,11 +19,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins : base(dnsClient, logService, settings) => _client = new DnsManagementClient(options.ApiKey.Value, logService); - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { try { - await _client.CreateRecord(recordName, RecordType.TXT, token); + await _client.CreateRecord(record.Authority.Domain, RecordType.TXT, record.Value); return true; } catch @@ -31,11 +32,11 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins } } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { try { - await _client.DeleteRecord(recordName, RecordType.TXT, token); + await _client.DeleteRecord(record.Authority.Domain, RecordType.TXT, record.Value); } catch (Exception ex) { diff --git a/src/plugin.validation.dns.luadns/luadns.cs b/src/plugin.validation.dns.luadns/luadns.cs index 4935681..c95a2bf 100644 --- a/src/plugin.validation.dns.luadns/luadns.cs +++ b/src/plugin.validation.dns.luadns/luadns.cs @@ -1,4 +1,5 @@ using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Services; using System; using System.Collections.Generic; @@ -64,7 +65,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns _apiKey = options.APIKey.Value; } - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { _log.Information("Creating LuaDNS verification record"); @@ -78,14 +79,14 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns var payload = await response.Content.ReadAsStringAsync(); var zones = JsonSerializer.Deserialize<ZoneData[]>(payload); - var targetZone = FindBestMatch(zones.ToDictionary(x => x.Name), recordName); + var targetZone = FindBestMatch(zones.ToDictionary(x => x.Name), record.Authority.Domain); if (targetZone == null) { _log.Error("No matching zone found in LuaDNS account. Aborting"); return false; } - var newRecord = new RecordData { Name = $"{recordName}.", Type = "TXT", Content = token, TTL = 300 }; + var newRecord = new RecordData { Name = $"{record.Authority.Domain}.", Type = "TXT", Content = record.Value, TTL = 300 }; payload = JsonSerializer.Serialize(newRecord); response = await client.PostAsync(new Uri(_LuaDnsApiEndpoint, $"zones/{targetZone.Id}/records"), new StringContent(payload, Encoding.UTF8, "application/json")); @@ -97,29 +98,30 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns payload = await response.Content.ReadAsStringAsync(); newRecord = JsonSerializer.Deserialize<RecordData>(payload); - _recordsMap[recordName] = newRecord; + _recordsMap[record.Authority.Domain] = newRecord; return true; } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { - if (!_recordsMap.ContainsKey(recordName)) + if (!_recordsMap.ContainsKey(record.Authority.Domain)) { - _log.Warning($"No record with name {recordName} was created"); + _log.Warning($"No record with name {record.Authority.Domain} was created"); return; } _log.Information("Deleting LuaDNS verification record"); using var client = GetClient(); - var response = await client.DeleteAsync(new Uri(_LuaDnsApiEndpoint, $"zones/{_recordsMap[recordName].ZoneId}/records/{_recordsMap[recordName].Id}")); + var created = _recordsMap[record.Authority.Domain]; + var response = await client.DeleteAsync(new Uri(_LuaDnsApiEndpoint, $"zones/{created.ZoneId}/records/{created.Id}")); if (!response.IsSuccessStatusCode) { _log.Warning("Failed to delete DNS verification record"); return; } - _ = _recordsMap.Remove(recordName); + _ = _recordsMap.Remove(record.Authority.Domain); } private HttpClient GetClient() diff --git a/src/plugin.validation.dns.route53/Route53.cs b/src/plugin.validation.dns.route53/Route53.cs index a2d490c..38aae55 100644 --- a/src/plugin.validation.dns.route53/Route53.cs +++ b/src/plugin.validation.dns.route53/Route53.cs @@ -1,10 +1,11 @@ -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; @@ -14,16 +15,13 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns internal sealed class Route53 : DnsValidation<Route53> { private readonly IAmazonRoute53 _route53Client; - private readonly DomainParseService _domainParser; public Route53( LookupClientProvider dnsClient, - DomainParseService domainParser, ILogService log, ProxyService proxy, ISettingsService settings, - Route53Options options) - : base(dnsClient, log, settings) + Route53Options options) : base(dnsClient, log, settings) { var region = RegionEndpoint.USEast1; var config = new AmazonRoute53Config() { RegionEndpoint = region }; @@ -33,7 +31,6 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns : !string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey.Value) ? new AmazonRoute53Client(options.AccessKeyId, options.SecretAccessKey.Value, config) : new AmazonRoute53Client(config); - _domainParser = domainParser; } private static ResourceRecordSet CreateResourceRecordSet(string name, string value) @@ -48,25 +45,30 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns }; } - public override async Task<bool> CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { try { - var hostedZoneId = await GetHostedZoneId(recordName); - if (hostedZoneId == null) + var recordName = record.Authority.Domain; + var token = record.Value; + var hostedZoneIds = await GetHostedZoneIds(recordName); + if (hostedZoneIds == null) { return false; } _log.Information("Creating TXT record {recordName} with value {token}", recordName, token); - var response = await _route53Client.ChangeResourceRecordSetsAsync( - new ChangeResourceRecordSetsRequest( - hostedZoneId, - new ChangeBatch(new List<Change> { - new Change( - ChangeAction.UPSERT, - CreateResourceRecordSet(recordName, token)) - }))); - await WaitChangesPropagation(response.ChangeInfo); + var updateTasks = hostedZoneIds.Select(hostedZoneId => + _route53Client.ChangeResourceRecordSetsAsync( + new ChangeResourceRecordSetsRequest( + hostedZoneId, + new ChangeBatch(new List<Change> { + new Change( + ChangeAction.UPSERT, + CreateResourceRecordSet(recordName, token)) + })))); + var results = await Task.WhenAll(updateTasks); + var propagationTasks = results.Select(result => WaitChangesPropagation(result.ChangeInfo)); + await Task.WhenAll(propagationTasks); return true; } catch (Exception ex) @@ -76,20 +78,24 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { - var hostedZoneId = await GetHostedZoneId(recordName); + var recordName = record.Authority.Domain; + var token = record.Value; + var hostedZoneIds = await GetHostedZoneIds(recordName); _log.Information($"Deleting TXT record {recordName} with value {token}"); - _ = await _route53Client.ChangeResourceRecordSetsAsync( - new ChangeResourceRecordSetsRequest(hostedZoneId, - new ChangeBatch(new List<Change> { + var deleteTasks = hostedZoneIds.Select(hostedZoneId => + _route53Client.ChangeResourceRecordSetsAsync( + new ChangeResourceRecordSetsRequest(hostedZoneId, + new ChangeBatch(new List<Change> { new Change( ChangeAction.DELETE, CreateResourceRecordSet(recordName, token)) - }))); + })))); + _ = await Task.WhenAll(deleteTasks); } - private async Task<string> GetHostedZoneId(string recordName) + private async Task<IEnumerable<string>> GetHostedZoneIds(string recordName) { var hostedZones = new List<HostedZone>(); var response = await _route53Client.ListHostedZonesAsync(); @@ -104,10 +110,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } _log.Debug("Found {count} hosted zones in AWS", hostedZones); - var hostedZone = FindBestMatch(hostedZones.ToDictionary(x => x.Name), recordName); + hostedZones = hostedZones.Where(x => !x.Config.PrivateZone).ToList(); + var hostedZoneSets = hostedZones.GroupBy(x => x.Name); + var hostedZone = FindBestMatch(hostedZoneSets.ToDictionary(x => x.Key), recordName); if (hostedZone != null) { - return hostedZone.Id; + return hostedZone.Select(x => x.Id); } _log.Error($"Can't find hosted zone for domain {recordName}"); return null; |