diff options
author | Hank McCord <Henry.McCord@ewudn.robins.af.mil> | 2020-07-06 12:57:53 -0400 |
---|---|---|
committer | Hank McCord <Henry.McCord@ewudn.robins.af.mil> | 2020-07-06 13:05:08 -0400 |
commit | 789cda2aa2cf2343c7ac7d3923ec12fe2ba1889d (patch) | |
tree | d34e99666f655790f95268b593ba995b13c23662 | |
parent | 7f3c13e454eff5a3c39d4b20ae662e924baec35a (diff) | |
parent | 25e4ebdadf35a0050eeeacf3cf607fad8ab8a641 (diff) | |
download | letsencrypt-win-simple-789cda2aa2cf2343c7ac7d3923ec12fe2ba1889d.zip letsencrypt-win-simple-789cda2aa2cf2343c7ac7d3923ec12fe2ba1889d.tar.gz letsencrypt-win-simple-789cda2aa2cf2343c7ac7d3923ec12fe2ba1889d.tar.bz2 |
Merge branch 2.1.9 into azure-environment-agnostic
123 files changed, 4246 insertions, 2581 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 46b5c83..3f21049 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,5 @@ # These are supported funding model platforms +github: [WouterTinus] patreon: WouterTinus custom: ["https://www.paypal.me/woutertinus"] diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml deleted file mode 100644 index ab6c6c8..0000000 --- a/.github/workflows/dotnetcore.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: .NET Core - -on: [pull_request] - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 3.1 - - name: Build with dotnet - run: dotnet build --configuration Release diff --git a/appveyor.yml b/appveyor.yml index 7522c7e..ff2cc11 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 2.1.7.{build} +version: 2.1.9.{build} image: Visual Studio 2019 platform: Any CPU shallow_clone: true @@ -27,6 +27,7 @@ build_script: - cmd: dotnet publish ./src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj -c Release - cmd: dotnet publish ./src/plugin.validation.dns.dreamhost/wacs.validation.dns.dreamhost.csproj -c Release - cmd: dotnet publish ./src/plugin.validation.dns.route53/wacs.validation.dns.route53.csproj -c Release + - cmd: dotnet publish ./src/plugin.validation.dns.luadns/wacs.validation.dns.luadns.csproj -c Release - cmd: dotnet publish ./src/plugin.validation.dns.cloudflare/wacs.validation.dns.cloudflare.csproj -c Release test_script: - cmd: cd %APPVEYOR_BUILD_FOLDER%/src/main.test/ diff --git a/build/create-artifacts.ps1 b/build/create-artifacts.ps1 index 6e305c0..d1d6b92 100644 --- a/build/create-artifacts.ps1 +++ b/build/create-artifacts.ps1 @@ -99,8 +99,8 @@ PluginRelease route53 plugin.validation.dns.route53 @( "AWSSDK.Route53.dll", "PKISharp.WACS.Plugins.ValidationPlugins.Route53.dll" ) -PluginRelease cloudflare plugin.validation.dns.luadns @( - "PKISharp.WACS.Plugins.ValidationPlugins.LUADNS.dll" +PluginRelease luadns plugin.validation.dns.luadns @( + "PKISharp.WACS.Plugins.ValidationPlugins.LuaDns.dll" ) PluginRelease cloudflare plugin.validation.dns.cloudflare @( "FluentCloudflare.dll", diff --git a/dist/Scripts/ImportADFS.ps1 b/dist/Scripts/ImportADFS.ps1 new file mode 100644 index 0000000..f250f28 --- /dev/null +++ b/dist/Scripts/ImportADFS.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS +Imports a cert from WACS renewal into Active Directory Federation Services +.DESCRIPTION +Note that this script is intended to be run via the install script plugin from win-acme via the batch script wrapper. As such, we use positional parameters to avoid issues with using a dash in the cmd line. +Note that this script only works on the primary ADFS farm server; you need to make sure to copy the certificates over yourself. + +Proper information should be available here + +https://github.com/PKISharp/win-acme/wiki/Install-Script + +or more generally, here + +https://github.com/PKISharp/win-acme/wiki/Example-Scripts + +.PARAMETER NewCertThumbprint +The exact thumbprint of the cert to be imported. The script will copy this cert to the Personal store if not already there. + + +.EXAMPLE + +ImportADFS.ps1 <certThumbprint> + +.NOTES + +#> + +param( + [Parameter(Position=0,Mandatory=$true)] + [string]$NewCertThumbprint +) + +$CertInStore = Get-ChildItem -Path Cert:\LocalMachine -Recurse | Where-Object {$_.thumbprint -eq $NewCertThumbprint} | Sort-Object -Descending | Select-Object -f 1 +if($CertInStore){ + try{ + # Cert must exist in the personal store of machine to bind to ADFS + if($CertInStore.PSPath -notlike "*LocalMachine\My\*"){ + $SourceStoreScope = 'LocalMachine' + $SourceStorename = $CertInStore.PSParentPath.split("\")[-1] + + $SourceStore = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store -ArgumentList $SourceStorename, $SourceStoreScope + $SourceStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + + $cert = $SourceStore.Certificates | Where-Object {$_.thumbprint -eq $CertInStore.Thumbprint} + + + + $DestStoreScope = 'LocalMachine' + $DestStoreName = 'My' + + $DestStore = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Store -ArgumentList $DestStoreName, $DestStoreScope + $DestStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $DestStore.Add($cert) + + + $SourceStore.Close() + $DestStore.Close() + + $CertInStore = Get-ChildItem -Path Cert:\LocalMachine\My -Recurse | Where-Object {$_.thumbprint -eq $NewCertThumbprint} | Sort-Object -Descending | Select-Object -f 1 + } + + Set-AdfsCertificate -CertificateType Service-Communications -Thumbprint $CertInStore.Thumbprint -ErrorAction Stop + Set-AdfsSslCertificate -Thumbprint $CertInStore.Thumbprint -ErrorAction Stop + Restart-Service adfssrv -Force -ErrorAction Stop + "Cert thumbprint set to ADFS and service restarted" + }catch{ + "Cert thumbprint was not set successfully" + "Error: $($Error[0])" + } +}else{ + "Cert thumbprint not found in the cert store... which is strange because it should be there." +} + diff --git a/src/ACMESharpCore b/src/ACMESharpCore -Subproject 697d645d39308a2d2af0259b9616cd729de051b +Subproject 34712cd284b1506e5472f350ab041738b33ad22 diff --git a/src/main.lib/Clients/Acme/AcmeClient.cs b/src/main.lib/Clients/Acme/AcmeClient.cs index 2eae016..5d8fed9 100644 --- a/src/main.lib/Clients/Acme/AcmeClient.cs +++ b/src/main.lib/Clients/Acme/AcmeClient.cs @@ -1,562 +1,596 @@ -using ACMESharp.Authorizations; -using ACMESharp.Crypto.JOSE; -using ACMESharp.Crypto.JOSE.Impl; -using ACMESharp.Protocol; -using ACMESharp.Protocol.Resources; -using Newtonsoft.Json; -using PKISharp.WACS.Extensions; -using PKISharp.WACS.Services; -using PKISharp.WACS.Services.Serialization; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Mail; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Clients.Acme -{ - /// <summary> - /// Main class that talks to the ACME server - /// </summary> - internal class AcmeClient : IDisposable - { - private const string RegistrationFileName = "Registration_v2"; - private const string SignerFileName = "Signer_v2"; - - public const string OrderReady = "ready"; - public const string OrderPending = "pending"; - public const string OrderProcessing = "processing"; - public const string OrderValid = "valid"; - - public const string AuthorizationValid = "valid"; - public const string AuthorizationInvalid = "invalid"; - public const string AuthorizationPending = "pending"; - public const string AuthorizationProcessing = "processing"; - - public const string ChallengeValid = "valid"; - - private readonly ILogService _log; - private readonly IInputService _input; - private readonly ISettingsService _settings; - private readonly IArgumentsService _arguments; - private readonly ProxyService _proxyService; - - private AcmeProtocolClient? _client; - private AccountSigner? _accountSigner; - private bool _initialized = false; - - public AcmeClient( - IInputService inputService, - IArgumentsService arguments, - ILogService log, - ISettingsService settings, - ProxyService proxy) - { - _log = log; - _settings = settings; - _arguments = arguments; - _input = inputService; - _proxyService = proxy; - } - - #region - Account and registration - - - internal async Task ConfigureAcmeClient() - { - _log.Verbose("Loading ACME account signer..."); - IJwsTool? signer = null; - var accountSigner = AccountSigner; - if (accountSigner != null) - { - signer = accountSigner.JwsTool(); - } - - var httpClient = _proxyService.GetHttpClient(); - httpClient.BaseAddress = _settings.BaseUri; - var client = PrepareClient(httpClient, signer); - try - { - client.Directory = await client.GetDirectoryAsync(); - } - catch (Exception) - { - // Perhaps the BaseUri *is* the directory, such - // as implemented by Digicert (#1434) - client.Directory.Directory = ""; - client.Directory = await client.GetDirectoryAsync(); - } - await client.GetNonceAsync(); - client.Account = await LoadAccount(client, signer); - if (client.Account == null) - { - throw new Exception("AcmeClient was unable to find or create an account"); - } - _client = client; - } - - internal AcmeProtocolClient PrepareClient(HttpClient httpClient, IJwsTool? signer) - { - AcmeProtocolClient? client = null; - _log.Verbose("Constructing ACME protocol client..."); - try - { - client = new AcmeProtocolClient( - httpClient, - signer: signer, - usePostAsGet: _settings.Acme.PostAsGet); - } - catch (CryptographicException) - { - if (signer == null) - { - // There has been a problem generate a signer for the - // new account, possibly because some EC curve is not - // on available on the system? So we give it another - // shot with a less fancy RSA signer - _log.Verbose("First chance error generating new signer, retrying with RSA instead of ECC"); - signer = new RSJwsTool - { - KeySize = _settings.Security.RSAKeyBits - }; - signer.Init(); - client = new AcmeProtocolClient( - httpClient, - signer: signer, - usePostAsGet: _settings.Acme.PostAsGet); - } - else - { - throw; - } - } - client.BeforeHttpSend = (x, r) => _log.Debug("Send {method} request to {uri}", r.Method, r.RequestUri); - client.AfterHttpSend = (x, r) => _log.Verbose("Request completed with status {s}", r.StatusCode); - return client; - } - - internal async Task<AccountDetails?> GetAccount() => (await GetClient()).Account; - - internal async Task<AcmeProtocolClient> GetClient() - { - if (!_initialized) - { - await ConfigureAcmeClient(); - _initialized = true; - } - if (_client == null) - { - throw new InvalidOperationException(); - } - return _client; - } - - private async Task<AccountDetails?> LoadAccount(AcmeProtocolClient client, IJwsTool? signer) - { - AccountDetails? account = null; - if (File.Exists(AccountPath)) - { - if (signer != null) - { - _log.Debug("Loading account information from {registrationPath}", AccountPath); - account = JsonConvert.DeserializeObject<AccountDetails>(File.ReadAllText(AccountPath)); - client.Account = account; - // Maybe we should update the account details - // on every start of the program to figure out - // if it hasn't been suspended or cancelled? - // UpdateAccount(); - } - else - { - _log.Error("Account found but no valid signer could be loaded"); - } - } - else - { - var contacts = await GetContacts(); - var (_, filename, content) = await client.GetTermsOfServiceAsync(); - if (!string.IsNullOrEmpty(filename)) - { - if (!await AcceptTos(filename, content)) - { - return null; - } - } - account = await client.CreateAccountAsync(contacts, termsOfServiceAgreed: true); - _log.Debug("Saving registration"); - var accountKey = new AccountSigner - { - KeyType = client.Signer.JwsAlg, - KeyExport = client.Signer.Export(), - }; - AccountSigner = accountKey; - File.WriteAllText(AccountPath, JsonConvert.SerializeObject(account)); - } - return account; - } - - /// <summary> - /// Ask the user to accept the terms of service dictated - /// by the ACME service operator - /// </summary> - /// <param name="filename"></param> - /// <param name="content"></param> - /// <returns></returns> - private async Task<bool> AcceptTos(string filename, byte[] content) - { - var tosPath = Path.Combine(_settings.Client.ConfigurationPath, filename); - File.WriteAllBytes(tosPath, content); - _input.Show($"Terms of service", tosPath); - if (_arguments.MainArguments.AcceptTos) - { - return true; - } - if (await _input.PromptYesNo($"Open in default application?", false)) - { - try - { - Process.Start(new ProcessStartInfo - { - FileName = tosPath, - UseShellExecute = true - }); - } - catch (Exception ex) - { - _log.Error(ex, "Unable to start application"); - } - } - return await _input.PromptYesNo($"Do you agree with the terms?", true); - } - - /// <summary> - /// Test the network connection - /// </summary> - internal async Task CheckNetwork() - { - using var httpClient = _proxyService.GetHttpClient(); - httpClient.BaseAddress = _settings.BaseUri; - try - { - _log.Verbose("SecurityProtocol setting: {setting}", System.Net.ServicePointManager.SecurityProtocol); - _ = await httpClient.GetAsync("directory"); - } - catch (Exception) - { - _log.Warning("No luck yet, attempting to force TLS 1.2..."); - _proxyService.SslProtocols = SslProtocols.Tls12; - using var altClient = _proxyService.GetHttpClient(); - altClient.BaseAddress = _settings.BaseUri; - try - { - _ = await altClient.GetAsync("directory"); - } - catch (Exception ex) - { - _log.Error(ex, "Unable to connect to ACME server"); - return; - } - } - _log.Debug("Connection OK!"); - } - - /// <summary> - /// Get contact information - /// </summary> - /// <returns></returns> - private async Task<string[]> GetContacts() - { - var email = _arguments.MainArguments.EmailAddress; - if (string.IsNullOrWhiteSpace(email)) - { - email = await _input.RequestString("Enter email(s) for notifications about problems and abuse (comma seperated)"); - } - var newEmails = email.ParseCsv(); - if (newEmails == null) - { - return new string[] { }; - } - newEmails = newEmails.Where(x => - { - try - { - new MailAddress(x); - return true; - } - catch - { - _log.Warning($"Invalid email: {x}"); - return false; - } - }).ToList(); - if (!newEmails.Any()) - { - _log.Warning("No (valid) email addresses specified"); - } - return newEmails.Select(x => $"mailto:{x}").ToArray(); - } - - /// <summary> - /// File that contains information about the signer, which - /// cryptographically signs the messages sent to the ACME - /// server so that the account can be authenticated - /// </summary> - private string SignerPath => Path.Combine(_settings.Client.ConfigurationPath, SignerFileName); - - /// <summary> - /// File that contains information about the account - /// </summary> - private string AccountPath => Path.Combine(_settings.Client.ConfigurationPath, RegistrationFileName); - - private AccountSigner? AccountSigner - { - get - { - if (_accountSigner == null) - { - if (File.Exists(SignerPath)) - { - try - { - _log.Debug("Loading signer from {SignerPath}", SignerPath); - var signerString = new ProtectedString(File.ReadAllText(SignerPath), _log); - _accountSigner = JsonConvert.DeserializeObject<AccountSigner>(signerString.Value); - } - catch (Exception ex) - { - _log.Error(ex, "Unable to load signer"); - } - } - } - return _accountSigner; - } - set - { - _log.Debug("Saving signer to {SignerPath}", SignerPath); - var x = new ProtectedString(JsonConvert.SerializeObject(value)); - File.WriteAllText(SignerPath, x.DiskValue(_settings.Security.EncryptConfig)); - _accountSigner = value; - } - } - - internal void EncryptSigner() - { - try - { - var signer = AccountSigner; - AccountSigner = signer; //forces a re-save of the signer - _log.Information("Signer re-saved"); - } - catch - { - _log.Error("Cannot re-save signer as it is likely encrypted on a different machine"); - } - } - - #endregion - - internal async Task<IChallengeValidationDetails> DecodeChallengeValidation(Authorization auth, Challenge challenge) - { - var client = await GetClient(); - return AuthorizationDecoder.DecodeChallengeValidation(auth, challenge.Type, client.Signer); - } - - internal async Task<Challenge> AnswerChallenge(Challenge challenge) - { - // Have to loop to wait for server to stop being pending - var client = await GetClient(); - challenge = await Retry(() => client.AnswerChallengeAsync(challenge.Url)); - var tries = 1; - while ( - challenge.Status == AuthorizationPending || - challenge.Status == AuthorizationProcessing) - { - await Task.Delay(_settings.Acme.RetryInterval * 1000); - _log.Debug("Refreshing authorization ({tries}/{count})", tries, _settings.Acme.RetryCount); - challenge = await Retry(() => client.GetChallengeDetailsAsync(challenge.Url)); - tries += 1; - if (tries > _settings.Acme.RetryCount) - { - break; - } - } - return challenge; - } - - internal async Task<OrderDetails> CreateOrder(IEnumerable<string> identifiers) - { - var client = await GetClient(); - return await Retry(() => client.CreateOrderAsync(identifiers)); - } - - internal async Task<Challenge> GetChallengeDetails(string url) - { - var client = await GetClient(); - return await Retry(() => client.GetChallengeDetailsAsync(url)); - } - - internal async Task<Authorization> GetAuthorizationDetails(string url) - { - var client = await GetClient(); - return await Retry(() => client.GetAuthorizationDetailsAsync(url)); - } - - /// <summary> - /// https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 - /// </summary> - /// <param name="details"></param> - /// <param name="csr"></param> - /// <returns></returns> - internal async Task<OrderDetails> SubmitCsr(OrderDetails details, byte[] csr) - { - // First wait for the order to get "ready", meaning that all validations - // are complete. The program makes sure this is the case at the level of - // individual authorizations, but the server might need some extra time to - // propagate this status at the order level. - var client = await GetClient(); - await WaitForOrderStatus(details, OrderReady, false); - if (details.Payload.Status == OrderReady) - { - details = await Retry(() => client.FinalizeOrderAsync(details.Payload.Finalize, csr)); - await WaitForOrderStatus(details, OrderProcessing, true); - } - return details; - } - - /// <summary> - /// Helper function to check/refresh order state - /// </summary> - /// <param name="details"></param> - /// <param name="status"></param> - /// <param name="negate"></param> - /// <returns></returns> - private async Task WaitForOrderStatus(OrderDetails details, string status, bool negate) - { - // Wait for processing - _ = await GetClient(); - var tries = 0; - do - { - if (tries > 0) - { - if (tries > _settings.Acme.RetryCount) - { - break; - } - _log.Debug($"Waiting for order to get {(negate ? "NOT " : "")}{{ready}} ({{tries}}/{{count}})", OrderReady, tries, _settings.Acme.RetryCount); - await Task.Delay(_settings.Acme.RetryInterval * 1000); - var update = await GetOrderDetails(details.OrderUrl); - details.Payload = update.Payload; - } - tries += 1; - } while ( - (negate && details.Payload.Status == status) || - (!negate && details.Payload.Status != status) - ); - } - - internal async Task<OrderDetails> GetOrderDetails(string url) - { - var client = await GetClient(); - return await Retry(() => client.GetOrderDetailsAsync(url)); - } - - internal async Task ChangeContacts() - { - var client = await GetClient(); - var contacts = await GetContacts(); - var account = await Retry(() => client.UpdateAccountAsync(contacts, client.Account)); - await UpdateAccount(); - } - - internal async Task UpdateAccount() - { - var client = await GetClient(); - var account = await Retry(() => client.CheckAccountAsync()); - File.WriteAllText(AccountPath, JsonConvert.SerializeObject(account)); - client.Account = account; - } - - internal async Task<byte[]> GetCertificate(OrderDetails order) - { - var client = await GetClient(); - return await Retry(() => client.GetOrderCertificateAsync(order)); - } - - internal async Task RevokeCertificate(byte[] crt) - { - var client = await GetClient(); - _ = await Retry(async () => client.RevokeCertificateAsync(crt, RevokeReason.Unspecified)); - } - - /// <summary> - /// According to the ACME standard, we SHOULD retry calls - /// if there is an invalid nonce. TODO: check for the proper - /// exception feedback, now *any* failed request is retried - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="executor"></param> - /// <returns></returns> - private async Task<T> Retry<T>(Func<Task<T>> executor, int attempt = 0) - { - try - { - return await executor(); - } - catch (AcmeProtocolException apex) - { - if (attempt < 3 && apex.ProblemType == ProblemType.BadNonce) - { - _log.Warning("First chance error calling into ACME server, retrying with new nonce..."); - var client = await GetClient(); - await client.GetNonceAsync(); - return await Retry(executor, attempt += 1); - } - else - { - throw; - } - } - } - - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing && _client != null) - { - _client.Dispose(); - } - - // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. - // TODO: set large fields to null. - - disposedValue = true; - } - } - - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~AcmeClient() - // { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. - public void Dispose() - { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); - } - #endregion - } +using ACMESharp.Authorizations;
+using ACMESharp.Crypto.JOSE;
+using ACMESharp.Crypto.JOSE.Impl;
+using ACMESharp.Protocol;
+using ACMESharp.Protocol.Resources;
+using Newtonsoft.Json;
+using PKISharp.WACS.Extensions;
+using PKISharp.WACS.Services;
+using PKISharp.WACS.Services.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+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
+{
+ /// <summary>
+ /// Main class that talks to the ACME server
+ /// </summary>
+ internal class AcmeClient : IDisposable
+ {
+ private const string RegistrationFileName = "Registration_v2";
+ private const string SignerFileName = "Signer_v2";
+
+ /// <summary>
+ /// https://tools.ietf.org/html/rfc8555#section-7.1.6
+ /// </summary>
+ public const string OrderPending = "pending"; // new order
+ public const string OrderReady = "ready"; // all authorizations done
+ public const string OrderProcessing = "processing"; // busy issuing
+ public const string OrderInvalid = "invalid"; // validation/order error
+ public const string OrderValid = "valid"; // certificate issued
+
+ public const string AuthorizationValid = "valid";
+ public const string AuthorizationInvalid = "invalid";
+ public const string AuthorizationPending = "pending";
+ public const string AuthorizationProcessing = "processing";
+
+ public const string ChallengeValid = "valid";
+
+ private readonly ILogService _log;
+ private readonly IInputService _input;
+ private readonly ISettingsService _settings;
+ private readonly IArgumentsService _arguments;
+ private readonly ProxyService _proxyService;
+
+ private AcmeProtocolClient? _client;
+ private AccountSigner? _accountSigner;
+ private bool _initialized = false;
+
+ public AcmeClient(
+ IInputService inputService,
+ IArgumentsService arguments,
+ ILogService log,
+ ISettingsService settings,
+ ProxyService proxy)
+ {
+ _log = log;
+ _settings = settings;
+ _arguments = arguments;
+ _input = inputService;
+ _proxyService = proxy;
+ }
+
+ #region - Account and registration -
+
+ internal async Task ConfigureAcmeClient()
+ {
+ _log.Verbose("Loading ACME account signer...");
+ IJwsTool? signer = null;
+ var accountSigner = AccountSigner;
+ if (accountSigner != null)
+ {
+ signer = accountSigner.JwsTool();
+ }
+
+ var httpClient = _proxyService.GetHttpClient();
+ httpClient.BaseAddress = _settings.BaseUri;
+ var client = PrepareClient(httpClient, signer);
+ try
+ {
+ client.Directory = await client.GetDirectoryAsync();
+ }
+ catch (Exception)
+ {
+ // Perhaps the BaseUri *is* the directory, such
+ // as implemented by Digicert (#1434)
+ client.Directory.Directory = "";
+ client.Directory = await client.GetDirectoryAsync();
+ }
+ await client.GetNonceAsync();
+ client.Account = await LoadAccount(client, signer);
+ if (client.Account == null)
+ {
+ throw new Exception("AcmeClient was unable to find or create an account");
+ }
+ _client = client;
+ }
+
+ internal AcmeProtocolClient PrepareClient(HttpClient httpClient, IJwsTool? signer)
+ {
+ _log.Verbose("Constructing ACME protocol client...");
+ AcmeProtocolClient? client;
+ try
+ {
+ client = new AcmeProtocolClient(
+ httpClient,
+ signer: signer,
+ usePostAsGet: _settings.Acme.PostAsGet);
+ }
+ catch (CryptographicException)
+ {
+ if (signer == null)
+ {
+ // There has been a problem generate a signer for the
+ // new account, possibly because some EC curve is not
+ // on available on the system? So we give it another
+ // shot with a less fancy RSA signer
+ _log.Verbose("First chance error generating new signer, retrying with RSA instead of ECC");
+ signer = new RSJwsTool
+ {
+ KeySize = _settings.Security.RSAKeyBits
+ };
+ signer.Init();
+ client = new AcmeProtocolClient(
+ httpClient,
+ signer: signer,
+ usePostAsGet: _settings.Acme.PostAsGet);
+ }
+ else
+ {
+ throw;
+ }
+ }
+ return client;
+ }
+
+ internal async Task<AccountDetails?> GetAccount() => (await GetClient()).Account;
+
+ internal async Task<AcmeProtocolClient> GetClient()
+ {
+ if (!_initialized)
+ {
+ await ConfigureAcmeClient();
+ _initialized = true;
+ }
+ if (_client == null)
+ {
+ throw new InvalidOperationException();
+ }
+ return _client;
+ }
+
+ private async Task<AccountDetails?> LoadAccount(AcmeProtocolClient client, IJwsTool? signer)
+ {
+ AccountDetails? account = null;
+ if (File.Exists(AccountPath))
+ {
+ if (signer != null)
+ {
+ _log.Debug("Loading account information from {registrationPath}", AccountPath);
+ account = JsonConvert.DeserializeObject<AccountDetails>(File.ReadAllText(AccountPath));
+ client.Account = account;
+ // Maybe we should update the account details
+ // on every start of the program to figure out
+ // if it hasn't been suspended or cancelled?
+ // UpdateAccount();
+ }
+ else
+ {
+ _log.Error("Account found but no valid signer could be loaded");
+ }
+ }
+ else
+ {
+ var contacts = await GetContacts();
+ try
+ {
+ var (_, filename, content) = await client.GetTermsOfServiceAsync();
+ if (!string.IsNullOrEmpty(filename))
+ {
+ if (!await AcceptTos(filename, content))
+ {
+ return null;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error getting terms of service");
+ }
+
+ try
+ {
+ account = await client.CreateAccountAsync(contacts, termsOfServiceAgreed: true);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error creating account");
+ }
+
+ try
+ {
+ _log.Debug("Saving account");
+ var accountKey = new AccountSigner
+ {
+ KeyType = client.Signer.JwsAlg,
+ KeyExport = client.Signer.Export(),
+ };
+ AccountSigner = accountKey;
+ File.WriteAllText(AccountPath, JsonConvert.SerializeObject(account));
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error saving account");
+ account = null;
+ }
+ }
+ return account;
+ }
+
+ /// <summary>
+ /// Ask the user to accept the terms of service dictated
+ /// by the ACME service operator
+ /// </summary>
+ /// <param name="filename"></param>
+ /// <param name="content"></param>
+ /// <returns></returns>
+ private async Task<bool> AcceptTos(string filename, byte[] content)
+ {
+ var tosPath = Path.Combine(_settings.Client.ConfigurationPath, filename);
+ File.WriteAllBytes(tosPath, content);
+ _input.Show($"Terms of service", tosPath);
+ if (_arguments.MainArguments.AcceptTos)
+ {
+ return true;
+ }
+ if (await _input.PromptYesNo($"Open in default application?", false))
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = tosPath,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Unable to start application");
+ }
+ }
+ return await _input.PromptYesNo($"Do you agree with the terms?", true);
+ }
+
+ /// <summary>
+ /// Test the network connection
+ /// </summary>
+ internal async Task CheckNetwork()
+ {
+ using var httpClient = _proxyService.GetHttpClient();
+ httpClient.BaseAddress = _settings.BaseUri;
+ try
+ {
+ _log.Verbose("SecurityProtocol setting: {setting}", System.Net.ServicePointManager.SecurityProtocol);
+ _ = await httpClient.GetAsync("directory");
+ }
+ catch (Exception)
+ {
+ _log.Warning("No luck yet, attempting to force TLS 1.2...");
+ _proxyService.SslProtocols = SslProtocols.Tls12;
+ using var altClient = _proxyService.GetHttpClient();
+ altClient.BaseAddress = _settings.BaseUri;
+ try
+ {
+ _ = await altClient.GetAsync("directory");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Unable to connect to ACME server");
+ return;
+ }
+ }
+ _log.Debug("Connection OK!");
+ }
+
+ /// <summary>
+ /// Get contact information
+ /// </summary>
+ /// <returns></returns>
+ private async Task<string[]> GetContacts()
+ {
+ var email = _arguments.MainArguments.EmailAddress;
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ email = await _input.RequestString("Enter email(s) for notifications about problems and abuse (comma seperated)");
+ }
+ var newEmails = email.ParseCsv();
+ if (newEmails == null)
+ {
+ return new string[] { };
+ }
+ newEmails = newEmails.Where(x =>
+ {
+ try
+ {
+ new MailAddress(x);
+ return true;
+ }
+ catch
+ {
+ _log.Warning($"Invalid email: {x}");
+ return false;
+ }
+ }).ToList();
+ if (!newEmails.Any())
+ {
+ _log.Warning("No (valid) email addresses specified");
+ }
+ return newEmails.Select(x => $"mailto:{x}").ToArray();
+ }
+
+ /// <summary>
+ /// File that contains information about the signer, which
+ /// cryptographically signs the messages sent to the ACME
+ /// server so that the account can be authenticated
+ /// </summary>
+ private string SignerPath => Path.Combine(_settings.Client.ConfigurationPath, SignerFileName);
+
+ /// <summary>
+ /// File that contains information about the account
+ /// </summary>
+ private string AccountPath => Path.Combine(_settings.Client.ConfigurationPath, RegistrationFileName);
+
+ private AccountSigner? AccountSigner
+ {
+ get
+ {
+ if (_accountSigner == null)
+ {
+ if (File.Exists(SignerPath))
+ {
+ try
+ {
+ _log.Debug("Loading signer from {SignerPath}", SignerPath);
+ var signerString = new ProtectedString(File.ReadAllText(SignerPath), _log);
+ _accountSigner = JsonConvert.DeserializeObject<AccountSigner>(signerString.Value);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Unable to load signer");
+ }
+ }
+ }
+ return _accountSigner;
+ }
+ set
+ {
+ _log.Debug("Saving signer to {SignerPath}", SignerPath);
+ var x = new ProtectedString(JsonConvert.SerializeObject(value));
+ File.WriteAllText(SignerPath, x.DiskValue(_settings.Security.EncryptConfig));
+ _accountSigner = value;
+ }
+ }
+
+ internal void EncryptSigner()
+ {
+ try
+ {
+ var signer = AccountSigner;
+ AccountSigner = signer; //forces a re-save of the signer
+ _log.Information("Signer re-saved");
+ }
+ catch
+ {
+ _log.Error("Cannot re-save signer as it is likely encrypted on a different machine");
+ }
+ }
+
+ #endregion
+
+ internal async Task<IChallengeValidationDetails> DecodeChallengeValidation(Authorization auth, Challenge challenge)
+ {
+ var client = await GetClient();
+ return AuthorizationDecoder.DecodeChallengeValidation(auth, challenge.Type, client.Signer);
+ }
+
+ internal async Task<Challenge> AnswerChallenge(Challenge challenge)
+ {
+ // Have to loop to wait for server to stop being pending
+ var client = await GetClient();
+ challenge = await Retry(() => client.AnswerChallengeAsync(challenge.Url));
+ var tries = 1;
+ while (
+ challenge.Status == AuthorizationPending ||
+ challenge.Status == AuthorizationProcessing)
+ {
+ await Task.Delay(_settings.Acme.RetryInterval * 1000);
+ _log.Debug("Refreshing authorization ({tries}/{count})", tries, _settings.Acme.RetryCount);
+ challenge = await Retry(() => client.GetChallengeDetailsAsync(challenge.Url));
+ tries += 1;
+ if (tries > _settings.Acme.RetryCount)
+ {
+ break;
+ }
+ }
+ return challenge;
+ }
+
+ internal async Task<OrderDetails> CreateOrder(IEnumerable<string> identifiers)
+ {
+ var client = await GetClient();
+ return await Retry(() => client.CreateOrderAsync(identifiers));
+ }
+
+ internal async Task<Challenge> GetChallengeDetails(string url)
+ {
+ var client = await GetClient();
+ return await Retry(() => client.GetChallengeDetailsAsync(url));
+ }
+
+ internal async Task<Authorization> GetAuthorizationDetails(string url)
+ {
+ var client = await GetClient();
+ return await Retry(() => client.GetAuthorizationDetailsAsync(url));
+ }
+
+ /// <summary>
+ /// https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3
+ /// </summary>
+ /// <param name="details"></param>
+ /// <param name="csr"></param>
+ /// <returns></returns>
+ internal async Task<OrderDetails> SubmitCsr(OrderDetails details, byte[] csr)
+ {
+ // First wait for the order to get "ready", meaning that all validations
+ // are complete. The program makes sure this is the case at the level of
+ // individual authorizations, but the server might need some extra time to
+ // propagate this status at the order level.
+ var client = await GetClient();
+ await WaitForOrderStatus(details, OrderReady);
+ if (details.Payload.Status == OrderReady)
+ {
+ details = await Retry(() => client.FinalizeOrderAsync(details.Payload.Finalize, csr));
+ await WaitForOrderStatus(details, OrderProcessing, true);
+ }
+ return details;
+ }
+
+ /// <summary>
+ /// Helper function to check/refresh order state
+ /// </summary>
+ /// <param name="details"></param>
+ /// <param name="status"></param>
+ /// <param name="negate"></param>
+ /// <returns></returns>
+ private async Task WaitForOrderStatus(OrderDetails details, string status, bool negate = false)
+ {
+ // Wait for processing
+ _ = await GetClient();
+ var tries = 0;
+ do
+ {
+ if (tries > 0)
+ {
+ if (tries > _settings.Acme.RetryCount)
+ {
+ break;
+ }
+ _log.Debug($"Waiting for order to get {(negate ? "NOT " : "")}{{ready}} ({{tries}}/{{count}})", status, tries, _settings.Acme.RetryCount);
+ await Task.Delay(_settings.Acme.RetryInterval * 1000);
+ var update = await GetOrderDetails(details.OrderUrl);
+ details.Payload = update.Payload;
+ }
+ tries += 1;
+ } while (
+ (negate && details.Payload.Status == status) ||
+ (!negate && details.Payload.Status != status)
+ );
+ }
+
+ internal async Task<OrderDetails> GetOrderDetails(string url)
+ {
+ var client = await GetClient();
+ return await Retry(() => client.GetOrderDetailsAsync(url));
+ }
+
+ internal async Task ChangeContacts()
+ {
+ var client = await GetClient();
+ var contacts = await GetContacts();
+ var account = await Retry(() => client.UpdateAccountAsync(contacts, client.Account));
+ await UpdateAccount();
+ }
+
+ internal async Task UpdateAccount()
+ {
+ var client = await GetClient();
+ var account = await Retry(() => client.CheckAccountAsync());
+ File.WriteAllText(AccountPath, JsonConvert.SerializeObject(account));
+ client.Account = account;
+ }
+
+ internal async Task<byte[]> GetCertificate(OrderDetails order)
+ {
+ var client = await GetClient();
+ return await Retry(() => client.GetOrderCertificateAsync(order));
+ }
+
+ internal async Task RevokeCertificate(byte[] crt)
+ {
+ var client = await GetClient();
+ _ = await Retry(async () => client.RevokeCertificateAsync(crt, RevokeReason.Unspecified));
+ }
+
+ /// <summary>
+ /// According to the ACME standard, we SHOULD retry calls
+ /// if there is an invalid nonce. TODO: check for the proper
+ /// exception feedback, now *any* failed request is retried
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="executor"></param>
+ /// <returns></returns>
+ private async Task<T> Retry<T>(Func<Task<T>> executor, int attempt = 0)
+ {
+ await _requestLock.WaitAsync();
+ try
+ {
+ return await executor();
+ }
+ catch (AcmeProtocolException apex)
+ {
+ if (attempt < 3 && apex.ProblemType == ProblemType.BadNonce)
+ {
+ _log.Warning("First chance error calling into ACME server, retrying with new nonce...");
+ var client = await GetClient();
+ await client.GetNonceAsync();
+ return await Retry(executor, attempt += 1);
+ }
+ else
+ {
+ throw;
+ }
+ }
+ finally
+ {
+ _requestLock.Release();
+ }
+ }
+
+ /// <summary>
+ /// Prevent sending simulateous requests to the ACME service because it messes
+ /// up the nonce tracking mechanism
+ /// </summary>
+ private readonly SemaphoreSlim _requestLock = new SemaphoreSlim(1, 1);
+
+ #region IDisposable Support
+ private bool disposedValue = false; // To detect redundant calls
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposedValue)
+ {
+ if (disposing && _client != null)
+ {
+ _client.Dispose();
+ }
+
+ // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below.
+ // TODO: set large fields to null.
+
+ disposedValue = true;
+ }
+ }
+
+ // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources.
+ // ~AcmeClient()
+ // {
+ // // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
+ // Dispose(false);
+ // }
+
+ // This code added to correctly implement the disposable pattern.
+ public void Dispose() =>
+ // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
+ Dispose(true);// TODO: uncomment the following line if the finalizer is overridden above.// GC.SuppressFinalize(this);
+ #endregion
+ }
}
\ No newline at end of file diff --git a/src/main.lib/Clients/Acme/OrderManager.cs b/src/main.lib/Clients/Acme/OrderManager.cs index b57ac8f..51a30c3 100644 --- a/src/main.lib/Clients/Acme/OrderManager.cs +++ b/src/main.lib/Clients/Acme/OrderManager.cs @@ -1,194 +1,192 @@ -using ACMESharp.Protocol; -using Newtonsoft.Json; -using PKISharp.WACS.Configuration; -using PKISharp.WACS.DomainObjects; -using PKISharp.WACS.Extensions; -using PKISharp.WACS.Services; -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Clients.Acme -{ - /// <summary> - /// The OrderManager makes sure that we don't hit rate limits - /// </summary> - class OrderManager - { - private readonly ILogService _log; - private readonly ISettingsService _settings; - private readonly AcmeClient _client; - private readonly DirectoryInfo _orderPath; - private readonly ICertificateService _certificateService; - private const string _orderFileExtension = "order.json"; - - public OrderManager(ILogService log, ISettingsService settings, - AcmeClient client, ICertificateService certificateService) - { - _log = log; - _client = client; - _settings = settings; - _certificateService = certificateService; - _orderPath = new DirectoryInfo(Path.Combine(settings.Client.ConfigurationPath, "Orders")); - } - - /// <summary> - /// Get a previously cached order or if its too old, create a new one - /// </summary> - /// <param name="renewal"></param> - /// <param name="target"></param> - /// <returns></returns> - public async Task<OrderDetails?> GetOrCreate(Order order, RunLevel runLevel) - { - var cacheKey = _certificateService.CacheKey(order); - var existingOrder = FindRecentOrder(cacheKey); - if (existingOrder != null) - { - try - { - if (runLevel.HasFlag(RunLevel.IgnoreCache)) - { - _log.Warning("Cached order available but not used with the --{switch} switch.", - nameof(MainArguments.Force).ToLower()); - } - else - { - existingOrder = await RefreshOrder(existingOrder); - if (existingOrder.Payload.Status == AcmeClient.OrderValid) - { - _log.Warning("Using cached order. To force issue of a new certificate within {days} days, " + - "run with the --{switch} switch. Be ware that you might run into rate limits doing so.", - _settings.Cache.ReuseDays, - nameof(MainArguments.Force).ToLower()); - return existingOrder; - } - else - { - _log.Debug("Cached order has status {status}, discarding", existingOrder.Payload.Status); - } - } - } - catch (Exception ex) - { - _log.Warning("Unable to refresh cached order: {ex}", ex.Message); - } - } - var identifiers = order.Target.GetHosts(false); - return await CreateOrder(identifiers, cacheKey); - } - - /// <summary> - /// Update order details from the server - /// </summary> - /// <param name="order"></param> - /// <returns></returns> - private async Task<OrderDetails> RefreshOrder(OrderDetails order) - { - _log.Debug("Refreshing order..."); - var update = await _client.GetOrderDetails(order.OrderUrl); - order.Payload = update.Payload; - return order; - } - - private async Task<OrderDetails?> CreateOrder(IEnumerable<string> identifiers, string cacheKey) - { - _log.Verbose("Creating order for hosts: {identifiers}", identifiers); - var order = await _client.CreateOrder(identifiers); - // Check if the order is valid - if ((order.Payload.Status != AcmeClient.OrderReady && - order.Payload.Status != AcmeClient.OrderPending) || - order.Payload.Error != null) - { - _log.Error("Failed to create order {url}: {detail}", order.OrderUrl, order.Payload.Error.Detail); - return null; - } - else - { - _log.Verbose("Order {url} created", order.OrderUrl); - SaveOrder(order, cacheKey); - } - return order; - } - - /// <summary> - /// Check if we have a recent order that can be reused - /// to prevent hitting rate limits - /// </summary> - /// <param name="identifiers"></param> - /// <returns></returns> - private OrderDetails? FindRecentOrder(string cacheKey) - { - DeleteStaleFiles(); - var fi = new FileInfo(Path.Combine(_orderPath.FullName, $"{cacheKey}.{_orderFileExtension}")); - if (!fi.Exists || !IsValid(fi)) - { - return null; - } - try - { - var content = File.ReadAllText(fi.FullName); - var order = JsonConvert.DeserializeObject<OrderDetails>(content); - return order; - } - catch (Exception ex) - { - _log.Warning("Unable to read order cache: {ex}", ex.Message); - } - return null; - } - - /// <summary> - /// Delete files that are not valid anymore - /// </summary> - private void DeleteStaleFiles() - { - if (_orderPath.Exists) - { - var orders = _orderPath.EnumerateFiles($"*.{_orderFileExtension}"); - foreach (var order in orders) - { - if (!IsValid(order)) - { - try - { - order.Delete(); - } - catch (Exception ex) - { - _log.Warning("Unable to clean up order cache: {ex}", ex.Message); - } - } - } - } - } - - /// <summary> - /// Test if a cached order file is still usable - /// </summary> - /// <returns></returns> - private bool IsValid(FileInfo order) => order.LastWriteTime > DateTime.Now.AddDays(_settings.Cache.ReuseDays * -1); - - /// <summary> - /// Save order to disk - /// </summary> - /// <param name="order"></param> - private void SaveOrder(OrderDetails order, string cacheKey) - { - try - { - if (!_orderPath.Exists) - { - _orderPath.Create(); - } - var content = JsonConvert.SerializeObject(order); - var path = Path.Combine(_orderPath.FullName, $"{cacheKey}.{_orderFileExtension}"); - File.WriteAllText(path, content); - } - catch (Exception ex) - { - _log.Warning("Unable to write to order cache: {ex}", ex.Message); - } - } - } -} +using ACMESharp.Protocol;
+using Newtonsoft.Json;
+using PKISharp.WACS.Configuration;
+using PKISharp.WACS.DomainObjects;
+using PKISharp.WACS.Extensions;
+using PKISharp.WACS.Services;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Clients.Acme
+{
+ /// <summary>
+ /// The OrderManager makes sure that we don't hit rate limits
+ /// </summary>
+ class OrderManager
+ {
+ private readonly ILogService _log;
+ private readonly ISettingsService _settings;
+ private readonly AcmeClient _client;
+ private readonly DirectoryInfo _orderPath;
+ private readonly ICertificateService _certificateService;
+ private const string _orderFileExtension = "order.json";
+
+ public OrderManager(ILogService log, ISettingsService settings,
+ AcmeClient client, ICertificateService certificateService)
+ {
+ _log = log;
+ _client = client;
+ _settings = settings;
+ _certificateService = certificateService;
+ _orderPath = new DirectoryInfo(Path.Combine(settings.Client.ConfigurationPath, "Orders"));
+ }
+
+ /// <summary>
+ /// Get a previously cached order or if its too old, create a new one
+ /// </summary>
+ /// <param name="renewal"></param>
+ /// <param name="target"></param>
+ /// <returns></returns>
+ public async Task<OrderDetails?> GetOrCreate(Order order, RunLevel runLevel)
+ {
+ var cacheKey = _certificateService.CacheKey(order);
+ var existingOrder = default(OrderDetails); // FindRecentOrder(cacheKey);
+ if (existingOrder != null)
+ {
+ try
+ {
+ if (runLevel.HasFlag(RunLevel.IgnoreCache))
+ {
+ _log.Warning("Cached order available but not used with the --{switch} switch.",
+ nameof(MainArguments.Force).ToLower());
+ }
+ else
+ {
+ existingOrder = await RefreshOrder(existingOrder);
+ if (existingOrder.Payload.Status == AcmeClient.OrderValid ||
+ existingOrder.Payload.Status == AcmeClient.OrderReady)
+ {
+ _log.Warning("Using cached order. To force issue of a new certificate within {days} days, " +
+ "run with the --{switch} switch. Be ware that you might run into rate limits doing so.",
+ _settings.Cache.ReuseDays,
+ nameof(MainArguments.Force).ToLower());
+ return existingOrder;
+ }
+ else
+ {
+ _log.Debug("Cached order has status {status}, discarding", existingOrder.Payload.Status);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Unable to refresh cached order: {ex}", ex.Message);
+ }
+ }
+ var identifiers = order.Target.GetHosts(false);
+ return await CreateOrder(identifiers, cacheKey);
+ }
+
+ /// <summary>
+ /// Update order details from the server
+ /// </summary>
+ /// <param name="order"></param>
+ /// <returns></returns>
+ private async Task<OrderDetails> RefreshOrder(OrderDetails order)
+ {
+ _log.Debug("Refreshing order...");
+ var update = await _client.GetOrderDetails(order.OrderUrl);
+ order.Payload = update.Payload;
+ return order;
+ }
+
+ private async Task<OrderDetails?> CreateOrder(IEnumerable<string> identifiers, string cacheKey)
+ {
+ _log.Verbose("Creating order for hosts: {identifiers}", identifiers);
+ var order = await _client.CreateOrder(identifiers);
+ if (order.Payload.Error != null)
+ {
+ _log.Error("Failed to create order {url}: {detail}", order.OrderUrl, order.Payload.Error.Detail);
+ return null;
+ }
+ else
+ {
+ _log.Verbose("Order {url} created", order.OrderUrl);
+ SaveOrder(order, cacheKey);
+ }
+ return order;
+ }
+
+ /// <summary>
+ /// Check if we have a recent order that can be reused
+ /// to prevent hitting rate limits
+ /// </summary>
+ /// <param name="identifiers"></param>
+ /// <returns></returns>
+ private OrderDetails? FindRecentOrder(string cacheKey)
+ {
+ DeleteStaleFiles();
+ var fi = new FileInfo(Path.Combine(_orderPath.FullName, $"{cacheKey}.{_orderFileExtension}"));
+ if (!fi.Exists || !IsValid(fi))
+ {
+ return null;
+ }
+ try
+ {
+ var content = File.ReadAllText(fi.FullName);
+ var order = JsonConvert.DeserializeObject<OrderDetails>(content);
+ return order;
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Unable to read order cache: {ex}", ex.Message);
+ }
+ return null;
+ }
+
+ /// <summary>
+ /// Delete files that are not valid anymore
+ /// </summary>
+ private void DeleteStaleFiles()
+ {
+ if (_orderPath.Exists)
+ {
+ var orders = _orderPath.EnumerateFiles($"*.{_orderFileExtension}");
+ foreach (var order in orders)
+ {
+ if (!IsValid(order))
+ {
+ try
+ {
+ order.Delete();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Unable to clean up order cache: {ex}", ex.Message);
+ }
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Test if a cached order file is still usable
+ /// </summary>
+ /// <returns></returns>
+ private bool IsValid(FileInfo order) => order.LastWriteTime > DateTime.Now.AddDays(_settings.Cache.ReuseDays * -1);
+
+ /// <summary>
+ /// Save order to disk
+ /// </summary>
+ /// <param name="order"></param>
+ private void SaveOrder(OrderDetails order, string cacheKey)
+ {
+ try
+ {
+ if (!_orderPath.Exists)
+ {
+ _orderPath.Create();
+ }
+ var content = JsonConvert.SerializeObject(order);
+ var path = Path.Combine(_orderPath.FullName, $"{cacheKey}.{_orderFileExtension}");
+ File.WriteAllText(path, content);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Unable to write to order cache: {ex}", ex.Message);
+ }
+ }
+ }
+}
diff --git a/src/main.lib/Clients/AcmeDnsClient.cs b/src/main.lib/Clients/AcmeDnsClient.cs index 0159d85..fb6cd0d 100644 --- a/src/main.lib/Clients/AcmeDnsClient.cs +++ b/src/main.lib/Clients/AcmeDnsClient.cs @@ -68,14 +68,16 @@ namespace PKISharp.WACS.Clients if (newReg != null) { // Verify correctness - _input.Show("Domain", domain, true); + _input.CreateSpace(); + _input.Show("Domain", domain); _input.Show("Record", $"_acme-challenge.{domain}"); _input.Show("Type", "CNAME"); _input.Show("Content", newReg.Fulldomain + "."); _input.Show("Note", "Some DNS control panels add the final dot automatically. Only one is required."); if (!await _input.Wait("Please press <Enter> after you've created and verified the record")) { - throw new Exception("User aborted"); + _log.Warning("User aborted"); + return false; } if (await VerifyRegistration(domain, newReg.Fulldomain, interactive)) { @@ -110,7 +112,7 @@ namespace PKISharp.WACS.Clients } else if (interactive && _input != null) { - if (!await _input.PromptYesNo("Unable to verify acme-dns configuration, press 'Y' or <ENTER> to retry, or 'N' to skip this step.", true)) + if (!await _input.PromptYesNo("Unable to verify acme-dns configuration, press 'Y' or <Enter> to retry, or 'N' to skip this step.", true)) { _log.Warning("Verification of acme-dns configuration skipped."); return true; @@ -131,24 +133,20 @@ namespace PKISharp.WACS.Clients /// <returns></returns> private async Task<bool> VerifyCname(string domain, string expected, int round) { - var dnsClients = await _dnsClient.GetClients(domain, round); + var authority = await _dnsClient.GetAuthority(domain, round, false); + var result = authority.Nameservers.ToList(); _log.Debug("Configuration will now be checked at name servers: {address}", - string.Join(", ", dnsClients.Select(x => x.IpAddress))); + string.Join(", ", result.Select(x => x.IpAddress))); // Parallel queries - var answers = await Task.WhenAll(dnsClients.Select(client => client.LookupClient.QueryAsync($"_acme-challenge.{domain}", DnsClient.QueryType.CNAME))); + var answers = await Task.WhenAll(result.Select(client => client.GetCname($"_acme-challenge.{domain}"))); // Loop through results - for (var i = 0; i < dnsClients.Count(); i++) + for (var i = 0; i < result.Count(); i++) { - var currentClient = dnsClients[i]; + var currentClient = result[i]; var currentResult = answers[i]; - var value = currentResult.Answers.CnameRecords(). - Select(cnameRecord => cnameRecord?.CanonicalName?.Value?.TrimEnd('.')). - Where(txtRecord => txtRecord != null). - FirstOrDefault(); - - if (string.Equals(expected, value, StringComparison.CurrentCultureIgnoreCase)) + if (string.Equals(expected, currentResult, StringComparison.CurrentCultureIgnoreCase)) { _log.Verbose("Verification of CNAME record successful at server {server}", currentClient.IpAddress); } @@ -156,7 +154,7 @@ namespace PKISharp.WACS.Clients { _log.Warning("Verification failed, {domain} found value {found} but expected {expected} at server {server}", $"_acme-challenge.{domain}", - value ?? "(null)", + currentResult ?? "(null)", expected, currentClient.IpAddress); return false; @@ -202,18 +200,18 @@ namespace PKISharp.WACS.Clients } } - public async Task Update(string domain, string token) + public async Task<bool> Update(string domain, string token) { var reg = RegistrationForDomain(domain); if (reg == null) { _log.Error("No registration found for domain {domain}", domain); - return; + return false; } if (reg.Fulldomain == null) { _log.Error("Registration for domain {domain} appears invalid", domain); - return; + return false; } if (!await VerifyCname(domain, reg.Fulldomain, 0)) { @@ -236,10 +234,12 @@ namespace PKISharp.WACS.Clients JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json")); + return true; } catch (Exception ex) { _log.Error(ex, "Error sending update request to acme-dns server at {baseUri} for domain {domain}", _baseUri, domain); + return false; } } diff --git a/src/main.lib/Clients/DNS/LookupClientProvider.cs b/src/main.lib/Clients/DNS/LookupClientProvider.cs index 061eac7..806fff7 100644 --- a/src/main.lib/Clients/DNS/LookupClientProvider.cs +++ b/src/main.lib/Clients/DNS/LookupClientProvider.cs @@ -119,28 +119,43 @@ namespace PKISharp.WACS.Clients.DNS return ret; } + public class DnsLookupResult + { + public DnsLookupResult(string domain, IEnumerable<LookupClientWrapper> nameServers, DnsLookupResult? cnameFrom = null) + { + Nameservers = nameServers; + Domain = domain; + From = cnameFrom; + } + + public IEnumerable<LookupClientWrapper> Nameservers { get; set; } + public string Domain { get; set; } + public DnsLookupResult? From { get; set; } + } + /// <summary> /// Get cached list of authoritative name server ip addresses /// </summary> /// <param name="domainName"></param> /// <param name="round"></param> /// <returns></returns> - private async Task<IEnumerable<IPAddress>> GetAuthoritativeNameServersForDomain(string domainName, int round) + public async Task<DnsLookupResult> GetAuthority(string domainName, int round = 0, bool followCnames = true, DnsLookupResult? from = null) { var key = domainName.ToLower().TrimEnd('.'); if (!_authoritativeNs.ContainsKey(key)) { try { - // _acme-challenge.sub.example.co.uk + // Example: _acme-challenge.sub.example.co.uk domainName = domainName.TrimEnd('.'); - // First domain we should try to ask + // First domain we should try to ask is the tld (e.g. co.uk) var rootDomain = _domainParser.GetTLD(domainName); var testZone = rootDomain; var client = GetDefaultClient(round); - // Other sub domains we should try asking: + // Other sub domains we should ask: + // 1. example // 1. sub // 2. _acme-challenge var remainingParts = domainName.Substring(0, domainName.LastIndexOf(rootDomain)) @@ -158,12 +173,24 @@ namespace PKISharp.WACS.Clients.DNS _log.Verbose("Querying server {server} about {part}", client.IpAddress, testZone); using (LogContext.PushProperty("Domain", testZone)) { - var tempResult = await client.GetAuthoritativeNameServers(testZone, round); + var tempResult = await client.GetNameServers(testZone, round); _authoritativeNs.Add(testZone, tempResult?.ToList() ?? ipSet ?? _defaultNs); } } ipSet = _authoritativeNs[testZone]; - client = Produce(ipSet.OrderBy(x => Guid.NewGuid()).First()); + client = Produce(ipSet.OrderBy(x => Guid.NewGuid()).First()); + + // CNAME only valid for full domain. Subdomains may be + // regular records again + if (followCnames && testZone == key) + { + var cname = await client.GetCname(testZone); + if (cname != null) + { + return await GetAuthority(cname, round, true, Produce(key, from)); + } + } + if (remainingParts.Any()) { testZone = $"{remainingParts.First()}.{testZone}"; @@ -187,22 +214,9 @@ namespace PKISharp.WACS.Clients.DNS _authoritativeNs.Add(key, _defaultNs); } } - return _authoritativeNs[key]; - } - - /// <summary> - /// Caches <see cref="LookupClient"/>s by domainName. - /// Use <see cref="DefaultClient"/> instead if a name server - /// for a specific domain name is not required. - /// </summary> - /// <param name="domainName"></param> - /// <returns>Returns an <see cref="ILookupClient"/> using a name - /// server associated with the specified domain name.</returns> - public async Task<List<LookupClientWrapper>> GetClients(string domainName, int round = 0) - { - var ipSet = await GetAuthoritativeNameServersForDomain(domainName, round); - return ipSet.Select(ip => Produce(ip)).ToList(); + return Produce(key, from); } + private DnsLookupResult Produce(string key, DnsLookupResult? parent = null) => new DnsLookupResult(key, _authoritativeNs[key].Select(ip => Produce(ip)), parent); } }
\ No newline at end of file diff --git a/src/main.lib/Clients/DNS/LookupClientWrapper.cs b/src/main.lib/Clients/DNS/LookupClientWrapper.cs index c520f1b..68ac481 100644 --- a/src/main.lib/Clients/DNS/LookupClientWrapper.cs +++ b/src/main.lib/Clients/DNS/LookupClientWrapper.cs @@ -2,8 +2,8 @@ using DnsClient.Protocol; using PKISharp.WACS.Services; using Serilog.Context; -using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -16,7 +16,7 @@ namespace PKISharp.WACS.Clients.DNS private readonly LookupClientProvider _provider; private readonly IPAddress? _ipAddress; - public ILookupClient LookupClient { get; private set; } + private ILookupClient _lookupClient { get; set; } public string IpAddress => _ipAddress?.ToString() ?? "[System]"; public LookupClientWrapper(ILogService logService, IPAddress? ipAddress, LookupClientProvider provider) @@ -26,16 +26,16 @@ namespace PKISharp.WACS.Clients.DNS new LookupClientOptions(new[] { _ipAddress }) : new LookupClientOptions(); clientOptions.UseCache = true; - LookupClient = new LookupClient(clientOptions); + _lookupClient = new LookupClient(clientOptions); _log = logService; _provider = provider; } - public async Task<IEnumerable<IPAddress>?> GetAuthoritativeNameServers(string domainName, int round) + public async Task<IEnumerable<IPAddress>?> GetNameServers(string host, int round) { - domainName = domainName.TrimEnd('.'); - _log.Debug("Querying name servers for {part}", domainName); - var nsResponse = await LookupClient.QueryAsync(domainName, QueryType.NS); + host = host.TrimEnd('.'); + _log.Debug("Querying name servers for {part}", host); + var nsResponse = await _lookupClient.QueryAsync(host, QueryType.NS); var nsRecords = nsResponse.Answers.NsRecords(); var cnameRecords = nsResponse.Answers.CnameRecords(); if (!nsRecords.Any() && !cnameRecords.Any()) @@ -44,19 +44,24 @@ namespace PKISharp.WACS.Clients.DNS } if (nsRecords.Any()) { - return GetNameServerIpAddresses(nsRecords.Select(n => n.NSDName.Value), round); + return GetIpAddresses(nsRecords.Select(n => n.NSDName.Value), round); } + //if (cnameRecords.Any()) + //{ + // var client = await _provider.GetClients(cnameRecords.First().CanonicalName.Value); + // return await _provider.GetAuthoritativeNameServersForDomain(cnameRecords.First().CanonicalName.Value, round); + //} return null; } - private IEnumerable<IPAddress> GetNameServerIpAddresses(IEnumerable<string> nsRecords, int round) + private IEnumerable<IPAddress> GetIpAddresses(IEnumerable<string> hosts, int round) { - foreach (var nsRecord in nsRecords) + foreach (var nsRecord in hosts) { using (LogContext.PushProperty("NameServer", nsRecord)) { _log.Verbose("Querying IP for name server"); - var aResponse = _provider.GetDefaultClient(round).LookupClient.Query(nsRecord, QueryType.A); + var aResponse = _provider.GetDefaultClient(round)._lookupClient.Query(nsRecord, QueryType.A); var nameServerIp = aResponse.Answers.ARecords().FirstOrDefault()?.Address; if (nameServerIp != null) { @@ -67,35 +72,29 @@ namespace PKISharp.WACS.Clients.DNS } } - public async Task<(IEnumerable<string>, string)> GetTextRecordValues(string challengeUri, int attempt) + public async Task<IEnumerable<string>> GetTxtRecords(string host) { - var result = await LookupClient.QueryAsync(challengeUri, QueryType.TXT); - string server; - (result, server) = await RecursivelyFollowCnames(challengeUri, result, attempt); - - var txtRecords = result.Answers.TxtRecords(). + var txtResult = await _lookupClient.QueryAsync(host, QueryType.TXT); + return txtResult.Answers.TxtRecords(). SelectMany(txtRecord => txtRecord?.EscapedText). Where(txtRecord => txtRecord != null). OfType<string>(). ToList(); - - return (txtRecords, server); } - - private async Task<(IDnsQueryResponse, string)> RecursivelyFollowCnames(string server, IDnsQueryResponse result, int attempt) + + public async Task<string?> GetCname(string host) { - if (result.Answers.CnameRecords().Any()) + var cnames = await _lookupClient.QueryAsync(host, QueryType.CNAME); + var cnameRecords = cnames.Answers.CnameRecords(); + if (cnameRecords.Any()) { - server = result.Answers.CnameRecords().First().CanonicalName; - var recursiveClients = await _provider.GetClients(server, attempt); - var index = attempt % recursiveClients.Count(); - var recursiveClient = recursiveClients.ElementAt(index); - var txtResponse = await recursiveClient.LookupClient.QueryAsync(server, QueryType.TXT); - _log.Debug("Name server {NameServerIpAddress} selected", txtResponse.NameServer.Address.ToString()); - return await recursiveClient.RecursivelyFollowCnames(server, txtResponse, attempt); + var canonicalName = cnameRecords.First().CanonicalName.Value; + var idn = new IdnMapping(); + canonicalName = canonicalName.ToLower().Trim().TrimEnd('.'); + canonicalName = idn.GetAscii(canonicalName); + return canonicalName; } - return (result, server); + return null; } - } }
\ No newline at end of file diff --git a/src/main.lib/Clients/EmailClient.cs b/src/main.lib/Clients/EmailClient.cs index a1a111d..4f77115 100644 --- a/src/main.lib/Clients/EmailClient.cs +++ b/src/main.lib/Clients/EmailClient.cs @@ -43,7 +43,10 @@ namespace PKISharp.WACS.Clients _secure = _settings.Notification.SmtpSecure; _secureMode = _settings.Notification.SmtpSecureMode; _senderName = _settings.Notification.SenderName; - _computerName = _settings.Notification.ComputerName ?? Environment.MachineName; + _computerName = _settings.Notification.ComputerName; + if (string.IsNullOrEmpty(_computerName)) { + _computerName = Environment.MachineName; + } _version = Assembly.GetEntryAssembly().GetName().Version.ToString(); if (string.IsNullOrWhiteSpace(_senderName)) @@ -63,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) { @@ -96,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) { @@ -112,23 +115,28 @@ namespace PKISharp.WACS.Clients Priority = priority, Subject = subject }; - message.Subject = subject; + message.Subject = $"{subject} ({_computerName})"; message.From.Add(sender); message.To.Add(receiver); 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) { @@ -137,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/BindingOptions.cs b/src/main.lib/Clients/IIS/BindingOptions.cs index f9555b5..a69ab9b 100644 --- a/src/main.lib/Clients/IIS/BindingOptions.cs +++ b/src/main.lib/Clients/IIS/BindingOptions.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Net.Sockets; namespace PKISharp.WACS.Clients.IIS { @@ -47,7 +49,28 @@ namespace PKISharp.WACS.Clients.IIS /// <summary> /// Binding string to use in IIS /// </summary> - public string Binding => $"{IP}:{Port}:{Host}"; + public string Binding + { + get + { + var formattedIP = IP; + if (!string.IsNullOrEmpty(formattedIP)) + { + if (formattedIP != "*") + { + if (IPAddress.TryParse(formattedIP, out var address)) + { + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + formattedIP = $"[{formattedIP}]"; + } + } + } + } + return $"{formattedIP}:{Port}:{Host}"; + } + } + public override string ToString() => Binding; /// <summary> diff --git a/src/main.lib/Clients/IIS/IISClient.cs b/src/main.lib/Clients/IIS/IISClient.cs index a0b82ee..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; } @@ -201,10 +208,10 @@ namespace PKISharp.WACS.Clients.IIS public void AddBinding(IISSiteWrapper site, BindingOptions options) { var newBinding = site.Site.Bindings.CreateElement("binding"); - newBinding.Protocol = "https"; newBinding.BindingInformation = options.Binding; newBinding.CertificateStoreName = options.Store; newBinding.CertificateHash = options.Thumbprint; + newBinding.Protocol = "https"; if (options.Flags > 0) { newBinding.SetAttributeValue("sslFlags", options.Flags); @@ -223,10 +230,10 @@ namespace PKISharp.WACS.Clients.IIS "certificateHash" }; var replacement = site.Site.Bindings.CreateElement("binding"); - replacement.Protocol = existingBinding.Protocol; replacement.BindingInformation = existingBinding.BindingInformation; replacement.CertificateStoreName = options.Store; replacement.CertificateHash = options.Thumbprint; + replacement.Protocol = existingBinding.Protocol; foreach (var attr in existingBinding.Binding.Attributes) { try diff --git a/src/main.lib/Clients/ScriptClient.cs b/src/main.lib/Clients/ScriptClient.cs index 0d0e4f6..a4e7723 100644 --- a/src/main.lib/Clients/ScriptClient.cs +++ b/src/main.lib/Clients/ScriptClient.cs @@ -80,7 +80,7 @@ namespace PKISharp.WACS.Clients process.EnableRaisingEvents = true; process.Exited += (s, e) => { - _log.Information(LogType.Event | LogType.Disk, output.ToString()); + _log.Information(LogType.Event | LogType.Disk | LogType.Notification, output.ToString()); exited = true; if (process.ExitCode != 0) { diff --git a/src/main.lib/Configuration/BaseArgumentsProvider.cs b/src/main.lib/Configuration/BaseArgumentsProvider.cs index e365084..df42949 100644 --- a/src/main.lib/Configuration/BaseArgumentsProvider.cs +++ b/src/main.lib/Configuration/BaseArgumentsProvider.cs @@ -8,6 +8,7 @@ namespace PKISharp.WACS.Configuration public abstract class BaseArgumentsProvider<T> : IArgumentsProvider<T> where T : class, new() { private readonly FluentCommandLineParser<T> _parser; + public ILogService? Log { get; set; } public BaseArgumentsProvider() { @@ -67,31 +68,36 @@ namespace PKISharp.WACS.Configuration return false; } - public virtual bool Validate(ILogService log, T current, MainArguments main) + public virtual bool Validate(T current, MainArguments main) { if (main.Renew) { if (IsActive(current)) { - log.Error($"Renewal {(string.IsNullOrEmpty(Group)?"":$"{Group} ")}parameters cannot be changed during a renewal. Recreate/overwrite the renewal or edit the .json file if you want to make changes."); + Log?.Error($"Renewal {(string.IsNullOrEmpty(Group)?"":$"{Group} ")}parameters cannot be changed during a renewal. Recreate/overwrite the renewal or edit the .json file if you want to make changes."); return false; } } return true; } - bool IArgumentsProvider.Validate(ILogService log, object current, MainArguments main) => Validate(log, (T)current, main); + bool IArgumentsProvider.Validate(object current, MainArguments main) => Validate((T)current, main); public IEnumerable<ICommandLineOption> Configuration => _parser.Options; public ICommandLineParserResult GetParseResult(string[] args) => _parser.Parse(args); - public T GetResult(string[] args) + public T? GetResult(string[] args) { - _parser.Parse(args); + var result = _parser.Parse(args); + if (result.HasErrors) + { + Log?.Error(result.ErrorText); + return null; + } return _parser.Object; } - object IArgumentsProvider.GetResult(string[] args) => GetResult(args); + object? IArgumentsProvider.GetResult(string[] args) => GetResult(args); } }
\ No newline at end of file diff --git a/src/main.lib/Configuration/NetworkCredentialOptions.cs b/src/main.lib/Configuration/NetworkCredentialOptions.cs index 51054b7..8417aa9 100644 --- a/src/main.lib/Configuration/NetworkCredentialOptions.cs +++ b/src/main.lib/Configuration/NetworkCredentialOptions.cs @@ -31,15 +31,15 @@ namespace PKISharp.WACS.Configuration public NetworkCredentialOptions(IArgumentsService arguments) { var args = arguments.GetArguments<NetworkCredentialArguments>(); - UserName = arguments.TryGetRequiredArgument(nameof(args.UserName), args.UserName); - Password = new ProtectedString(arguments.TryGetRequiredArgument(nameof(args.Password), args.Password)); + UserName = arguments.TryGetRequiredArgument(nameof(args.UserName), args?.UserName); + Password = new ProtectedString(arguments.TryGetRequiredArgument(nameof(args.Password), args?.Password)); } public NetworkCredentialOptions(IArgumentsService arguments, IInputService input) { var args = arguments.GetArguments<NetworkCredentialArguments>(); - UserName = arguments.TryGetArgument(args.UserName, input, "Username").Result; - Password = new ProtectedString(arguments.TryGetArgument(args.Password, input, "Password", true).Result); + UserName = arguments.TryGetArgument(args?.UserName, input, "Username").Result; + Password = new ProtectedString(arguments.TryGetArgument(args?.Password, input, "Password", true).Result); } } } 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/CsrPlugins/Ec/EcOptionsFactory.cs b/src/main.lib/Plugins/CsrPlugins/Ec/EcOptionsFactory.cs index 3898e8f..2597cc8 100644 --- a/src/main.lib/Plugins/CsrPlugins/Ec/EcOptionsFactory.cs +++ b/src/main.lib/Plugins/CsrPlugins/Ec/EcOptionsFactory.cs @@ -17,8 +17,8 @@ namespace PKISharp.WACS.Plugins.CsrPlugins var args = _arguments.GetArguments<CsrArguments>(); return Task.FromResult(new EcOptions() { - OcspMustStaple = args.OcspMustStaple ? true : (bool?)null, - ReusePrivateKey = args.ReusePrivateKey ? true : (bool?)null + OcspMustStaple = args?.OcspMustStaple ?? null, + ReusePrivateKey = args?.ReusePrivateKey ?? null }); } } diff --git a/src/main.lib/Plugins/CsrPlugins/Rsa/RsaOptionsFactory.cs b/src/main.lib/Plugins/CsrPlugins/Rsa/RsaOptionsFactory.cs index 1c34bf3..5ae93f0 100644 --- a/src/main.lib/Plugins/CsrPlugins/Rsa/RsaOptionsFactory.cs +++ b/src/main.lib/Plugins/CsrPlugins/Rsa/RsaOptionsFactory.cs @@ -20,8 +20,8 @@ namespace PKISharp.WACS.Plugins.CsrPlugins var args = _arguments.GetArguments<CsrArguments>(); return Task.FromResult(new RsaOptions() { - OcspMustStaple = args.OcspMustStaple ? true : (bool?)null, - ReusePrivateKey = args.ReusePrivateKey ? true : (bool?)null + OcspMustStaple = args?.OcspMustStaple ?? null, + ReusePrivateKey = args?.ReusePrivateKey ?? null }); } } diff --git a/src/main.lib/Plugins/InstallationPlugins/IISFtp/IISFtpOptionsFactory.cs b/src/main.lib/Plugins/InstallationPlugins/IISFtp/IISFtpOptionsFactory.cs index 92b78c5..f685d14 100644 --- a/src/main.lib/Plugins/InstallationPlugins/IISFtp/IISFtpOptionsFactory.cs +++ b/src/main.lib/Plugins/InstallationPlugins/IISFtp/IISFtpOptionsFactory.cs @@ -40,7 +40,7 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins { var args = _arguments.GetArguments<IISFtpArguments>(); var ret = new IISFtpOptions(); - var siteId = args.FtpSiteId; + var siteId = args?.FtpSiteId; if (siteId == null) { throw new Exception($"Missing parameter --{nameof(args.FtpSiteId).ToLower()}"); diff --git a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebArgumentsProvider.cs b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebArgumentsProvider.cs index f690e69..8b00da3 100644 --- a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebArgumentsProvider.cs +++ b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebArgumentsProvider.cs @@ -15,9 +15,9 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins public override string Group => "Installation"; public override string Condition => "--installation iis"; - public override bool Validate(ILogService log, IISWebArguments current, MainArguments main) + public override bool Validate(IISWebArguments current, MainArguments main) { - if (!base.Validate(log, current, main)) + if (!base.Validate(current, main)) { return false; } @@ -27,13 +27,13 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins { if (port < 1 || port > 65535) { - log.Error("Invalid --{param}, value should be between 1 and 65535", SslPortParameterName); + Log?.Error("Invalid --{param}, value should be between 1 and 65535", SslPortParameterName); return false; } } else { - log.Error("Invalid --{param}, value should be a number", SslPortParameterName); + Log?.Error("Invalid --{param}, value should be a number", SslPortParameterName); return false; } } @@ -41,7 +41,7 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins { if (!IPAddress.TryParse(current.SSLIPAddress, out _)) { - log.Error("Invalid --{sslipaddress}", SslIpParameterName); + Log?.Error("Invalid --{sslipaddress}", SslIpParameterName); return false; } } diff --git a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptions.cs b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptions.cs index a3a6cf8..490c8c7 100644 --- a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptions.cs +++ b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptions.cs @@ -15,14 +15,14 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins public override string Description => "Create or update https bindings in IIS"; public IISWebOptions() { } - public IISWebOptions(IISWebArguments args) + public IISWebOptions(IISWebArguments? args) { - var sslIp = args.SSLIPAddress; + var sslIp = args?.SSLIPAddress; if (!string.IsNullOrEmpty(sslIp) && sslIp != IISClient.DefaultBindingIp) { NewBindingIp = sslIp; } - var sslPortRaw = args.SSLPort; + var sslPortRaw = args?.SSLPort; if (!string.IsNullOrEmpty(sslPortRaw)) { // Already validated by the ArgumentsProvider diff --git a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptionsFactory.cs b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptionsFactory.cs index 4bb9431..7cdd2a7 100644 --- a/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptionsFactory.cs +++ b/src/main.lib/Plugins/InstallationPlugins/IISWeb/IISWebOptionsFactory.cs @@ -50,7 +50,7 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins { var args = _arguments.GetArguments<IISWebArguments>(); var ret = new IISWebOptions(args); - if (args.InstallationSiteId != null) + if (args?.InstallationSiteId != null) { // Throws exception when not found var site = _iisClient.GetWebSite(args.InstallationSiteId.Value); diff --git a/src/main.lib/Plugins/InstallationPlugins/Script/ScriptOptionsFactory.cs b/src/main.lib/Plugins/InstallationPlugins/Script/ScriptOptionsFactory.cs index 8037020..4cb2430 100644 --- a/src/main.lib/Plugins/InstallationPlugins/Script/ScriptOptionsFactory.cs +++ b/src/main.lib/Plugins/InstallationPlugins/Script/ScriptOptionsFactory.cs @@ -27,7 +27,7 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins inputService.Show("Full instructions", "https://www.win-acme.com/reference/plugins/installation/script"); do { - ret.Script = await _arguments.TryGetArgument(args.Script, inputService, "Enter the path to the script that you want to run after renewal"); + ret.Script = await _arguments.TryGetArgument(args?.Script, inputService, "Enter the path to the script that you want to run after renewal"); } while (!ret.Script.ValidFile(_log)); @@ -39,8 +39,13 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins inputService.Show("{StoreType}", $"Type of store ({CentralSslOptions.PluginName}/{CertificateStoreOptions.PluginName}/{PemFilesOptions.PluginName})"); inputService.Show("{StorePath}", "Path to the store"); inputService.Show("{RenewalId}", "Renewal identifier"); - - ret.ScriptParameters = await _arguments.TryGetArgument(args.ScriptParameters, inputService, "Enter the parameter format string for the script, e.g. \"--hostname {CertCommonName}\""); + inputService.Show("{OldCertCommonName}", "Common name (primary domain name) of the previously issued certificate"); + inputService.Show("{OldCertFriendlyName}", "Friendly name of the previously issued certificate"); + inputService.Show("{OldCertThumbprint}", "Thumbprint of the previously issued certificate"); + ret.ScriptParameters = await _arguments.TryGetArgument( + args?.ScriptParameters, + inputService, + "Enter the parameter format string for the script, e.g. \"--hostname {CertCommonName}\""); return ret; } @@ -49,13 +54,13 @@ namespace PKISharp.WACS.Plugins.InstallationPlugins var args = _arguments.GetArguments<ScriptArguments>(); var ret = new ScriptOptions { - Script = _arguments.TryGetRequiredArgument(nameof(args.Script), args.Script) + Script = _arguments.TryGetRequiredArgument(nameof(args.Script), args?.Script) }; if (!ret.Script.ValidFile(_log)) { throw new ArgumentException(nameof(args.Script)); } - ret.ScriptParameters = args.ScriptParameters; + ret.ScriptParameters = args?.ScriptParameters; return Task.FromResult(ret); } } 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/OrderPlugins/Domain/Domain.cs b/src/main.lib/Plugins/OrderPlugins/Domain/Domain.cs new file mode 100644 index 0000000..89266f1 --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Domain/Domain.cs @@ -0,0 +1,64 @@ +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Extensions; +using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Services; +using System.Collections.Generic; +using System.Linq; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + class Domain : IOrderPlugin + { + private readonly DomainParseService _domainParseService; + + public Domain(DomainParseService domainParseService) => _domainParseService = domainParseService; + + public IEnumerable<Order> Split(Renewal renewal, Target target) + { + var ret = new Dictionary<string, Order>(); + var parts = new Dictionary<string, List<TargetPart>>(); + foreach (var part in target.Parts) + { + foreach (var host in part.GetHosts(true)) + { + var domain = _domainParseService.GetRegisterableDomain(host.TrimStart('.', '*')); + var sourceParts = target.Parts.Where(p => p.GetHosts(true).Contains(host)); + if (!ret.ContainsKey(domain)) + { + var filteredParts = sourceParts.Select(p => new TargetPart(new List<string> { host }) { SiteId = p.SiteId }).ToList(); + var newTarget = new Target( + target.FriendlyName ?? "", + target.CommonName, + filteredParts); + var newOrder = new Order( + renewal, + newTarget, + friendlyNamePart: domain, + cacheKeyPart: domain); + ret.Add(domain, newOrder); + parts.Add(domain, filteredParts); + } + else + { + var existingOrder = ret[domain]; + var existingParts = parts[domain]; + foreach (var x in sourceParts) + { + var existingPart = existingParts.Where(x => x.SiteId == x.SiteId).FirstOrDefault(); + if (existingPart == null) + { + existingPart = new TargetPart(new[] { host }); + existingParts.Add(existingPart); + } + else if (!existingPart.Identifiers.Contains(host)) + { + existingPart.Identifiers.Add(host); + } + } + } + } + } + return ret.Values; + } + } +} diff --git a/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptions.cs b/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptions.cs new file mode 100644 index 0000000..fdee3da --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptions.cs @@ -0,0 +1,12 @@ +using PKISharp.WACS.Plugins.Base; +using PKISharp.WACS.Plugins.Base.Options; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + [Plugin("b7c331d4-d875-453e-b83a-2b537ca12535")] + internal class DomainOptions : OrderPluginOptions<Domain> + { + public override string Name => "Domain"; + public override string Description => "Seperate certificate for each domain"; + } +} diff --git a/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptionsFactory.cs b/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptionsFactory.cs new file mode 100644 index 0000000..203487b --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Domain/DomainOptionsFactory.cs @@ -0,0 +1,14 @@ +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Base.Factories; +using PKISharp.WACS.Services; +using System.Threading.Tasks; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + class DomainOptionsFactory : OrderPluginOptionsFactory<Domain, DomainOptions> + { + public override bool CanProcess(Target target) => target.CsrBytes == null; + public override Task<DomainOptions> Aquire(IInputService inputService, RunLevel runLevel) => Default(); + public override Task<DomainOptions> Default() => Task.FromResult(new DomainOptions()); + } +} diff --git a/src/main.lib/Plugins/OrderPlugins/Host/Host.cs b/src/main.lib/Plugins/OrderPlugins/Host/Host.cs index 7cc3f29..2488b74 100644 --- a/src/main.lib/Plugins/OrderPlugins/Host/Host.cs +++ b/src/main.lib/Plugins/OrderPlugins/Host/Host.cs @@ -27,7 +27,7 @@ namespace PKISharp.WACS.Plugins.OrderPlugins renewal, newTarget, friendlyNamePart: host, - cacheKeyPart: $"{host}|{part.SiteId ?? -1}"); + cacheKeyPart: host); ret.Add(newOrder); seen.Add(host); } diff --git a/src/main.lib/Plugins/OrderPlugins/Host/HostOptionsFactory.cs b/src/main.lib/Plugins/OrderPlugins/Host/HostOptionsFactory.cs index 719f1eb..ddd53c4 100644 --- a/src/main.lib/Plugins/OrderPlugins/Host/HostOptionsFactory.cs +++ b/src/main.lib/Plugins/OrderPlugins/Host/HostOptionsFactory.cs @@ -7,7 +7,7 @@ namespace PKISharp.WACS.Plugins.OrderPlugins { class HostOptionsFactory : OrderPluginOptionsFactory<Host, HostOptions> { - public override bool CanProcess(Target target) => true; + public override bool CanProcess(Target target) => target.CsrBytes == null; public override Task<HostOptions> Aquire(IInputService inputService, RunLevel runLevel) => Default(); public override Task<HostOptions> Default() => Task.FromResult(new HostOptions()); } diff --git a/src/main.lib/Plugins/OrderPlugins/Site/Site.cs b/src/main.lib/Plugins/OrderPlugins/Site/Site.cs new file mode 100644 index 0000000..7a300b5 --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Site/Site.cs @@ -0,0 +1,28 @@ +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Interfaces; +using System.Collections.Generic; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + class Site : IOrderPlugin + { + public IEnumerable<Order> Split(Renewal renewal, Target target) + { + var ret = new List<Order>(); + foreach (var part in target.Parts) + { + var newTarget = new Target( + target.FriendlyName ?? "", + target.CommonName, + new List<TargetPart> { part }); + var newOrder = new Order( + renewal, + newTarget, + friendlyNamePart: $"site {part.SiteId ?? -1}", + cacheKeyPart: $"{part.SiteId ?? -1}"); + ret.Add(newOrder); + } + return ret; + } + } +} diff --git a/src/main.lib/Plugins/OrderPlugins/Site/SiteOptions.cs b/src/main.lib/Plugins/OrderPlugins/Site/SiteOptions.cs new file mode 100644 index 0000000..ffe15f6 --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Site/SiteOptions.cs @@ -0,0 +1,12 @@ +using PKISharp.WACS.Plugins.Base; +using PKISharp.WACS.Plugins.Base.Options; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + [Plugin("74a42b2d-8eaa-4f40-ab6a-f55304254143")] + internal class SiteOptions : OrderPluginOptions<Site> + { + public override string Name => "Site"; + public override string Description => "Seperate certificate for each site"; + } +} diff --git a/src/main.lib/Plugins/OrderPlugins/Site/SiteOptionsFactory.cs b/src/main.lib/Plugins/OrderPlugins/Site/SiteOptionsFactory.cs new file mode 100644 index 0000000..42ed45d --- /dev/null +++ b/src/main.lib/Plugins/OrderPlugins/Site/SiteOptionsFactory.cs @@ -0,0 +1,14 @@ +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Base.Factories; +using PKISharp.WACS.Services; +using System.Threading.Tasks; + +namespace PKISharp.WACS.Plugins.OrderPlugins +{ + class SiteOptionsFactory : OrderPluginOptionsFactory<Site, SiteOptions> + { + public override bool CanProcess(Target target) => target.CsrBytes == null; + public override Task<SiteOptions> Aquire(IInputService inputService, RunLevel runLevel) => Default(); + public override Task<SiteOptions> Default() => Task.FromResult(new SiteOptions()); + } +} diff --git a/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs b/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs index 2c682ec..dc96981 100644 --- a/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs +++ b/src/main.lib/Plugins/Resolvers/InteractiveResolver.cs @@ -129,7 +129,8 @@ namespace PKISharp.WACS.Plugins.Resolvers // List options for generating new certificates if (!string.IsNullOrEmpty(longDescription)) { - _input.Show(null, longDescription, true); + _input.CreateSpace(); + _input.Show(null, longDescription); } Choice<IPluginOptionsFactory?> creator(T plugin, Type type, (bool, string?) disabled) { @@ -184,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 => @@ -202,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(), @@ -249,13 +260,17 @@ 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()] : ""; return await GetPlugin<IStorePluginOptionsFactory>( scope, - filter: (x) => x.Except(chosen), + filter: (x) => x, // Disable default null check defaultParam1: defaultParam1, defaultType: defaultType, defaultTypeFallback: typeof(PemFilesOptionsFactory), @@ -291,13 +306,17 @@ 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()] : ""; return await GetPlugin<IInstallationPluginOptionsFactory>( scope, - filter: (x) => x.Except(chosen), + filter: (x) => x, // Disable default null check unusable: x => (!x.CanInstall(storeTypes), "This step cannot be used in combination with the specified store(s)"), defaultParam1: defaultParam1, defaultType: defaultType, diff --git a/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs b/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs index a4d0d09..e8fd230 100644 --- a/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs +++ b/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs @@ -98,7 +98,7 @@ namespace PKISharp.WACS.Plugins.Resolvers { _log.Error("{n} plugin {x} not available: {m}. " + changeInstructions, char.ToUpper(className[0]) + className.Substring(1), - (defaultOption.plugin as IPluginOptionsFactory)?.Name ?? "Unknown", + defaultOption.plugin?.Name ?? "Unknown", defaultTypeDisabled.Item2); return nullResult; } diff --git a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs index 45b0282..6da551c 100644 --- a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs +++ b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs @@ -17,22 +17,42 @@ namespace PKISharp.WACS.Plugins.StorePlugins private readonly string _path; private readonly string? _password; + public static string? DefaultPath(ISettingsService settings) + { + var ret = settings.Store.PemFiles?.DefaultPath; + if (string.IsNullOrWhiteSpace(ret)) + { + ret = settings.Store.DefaultCentralSslStore; + } + return ret; + } + + public static string? DefaultPassword(ISettingsService settings) + { + var ret = settings.Store.CentralSsl?.DefaultPassword; + if (string.IsNullOrWhiteSpace(ret)) + { + ret = settings.Store.DefaultCentralSslPfxPassword; + } + return ret; + } + public CentralSsl(ILogService log, ISettingsService settings, CentralSslOptions options) { _log = log; - _password = !string.IsNullOrWhiteSpace(options.PfxPassword?.Value) ? - options.PfxPassword.Value : - settings.Store.DefaultCentralSslPfxPassword; + _password = !string.IsNullOrWhiteSpace(options.PfxPassword?.Value) ? + options.PfxPassword.Value : + DefaultPassword(settings); - var path = !string.IsNullOrWhiteSpace(options.Path) ? + var path = !string.IsNullOrWhiteSpace(options.Path) ? options.Path : - settings.Store.DefaultCentralSslStore; + DefaultPath(settings); if (path != null && path.ValidPath(log)) { _path = path; - _log.Debug("Using Centralized SSL path: {_path}", _path); + _log.Debug("Using CentralSsl path: {_path}", _path); } else { @@ -44,11 +64,11 @@ namespace PKISharp.WACS.Plugins.StorePlugins public Task Save(CertificateInfo input) { - _log.Information("Copying certificate to the Central SSL store"); + _log.Information("Copying certificate to the CentralSsl store"); foreach (var identifier in input.SanNames) { var dest = PathForIdentifier(identifier); - _log.Information("Saving certificate to Central SSL location {dest}", dest); + _log.Information("Saving certificate to CentralSsl location {dest}", dest); try { var collection = new X509Certificate2Collection @@ -60,7 +80,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins } catch (Exception ex) { - _log.Error(ex, "Error copying certificate to Central SSL store"); + _log.Error(ex, "Error copying certificate to CentralSsl store"); } } input.StoreInfo.Add(GetType(), @@ -74,7 +94,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins public Task Delete(CertificateInfo input) { - _log.Information("Removing certificate from the Central SSL store"); + _log.Information("Removing certificate from the CentralSsl store"); foreach (var identifier in input.SanNames) { var dest = PathForIdentifier(identifier); diff --git a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptions.cs b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptions.cs index 9579d47..fb4cbce 100644 --- a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptions.cs +++ b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptions.cs @@ -22,7 +22,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins internal const string PluginName = "CentralSsl"; public override string Name => PluginName; - public override string Description => "IIS Central Certificate Store (.pfx per domain)"; + public override string Description => "IIS Central Certificate Store (.pfx per host)"; /// <summary> /// Show details to the user diff --git a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptionsFactory.cs b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptionsFactory.cs index cd8fe09..97aaf95 100644 --- a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptionsFactory.cs +++ b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSslOptionsFactory.cs @@ -25,10 +25,10 @@ namespace PKISharp.WACS.Plugins.StorePlugins var args = _arguments.GetArguments<CentralSslArguments>(); // Get path from command line, default setting or user input - var path = args.CentralSslStore; + var path = args?.CentralSslStore; if (string.IsNullOrWhiteSpace(path)) { - path = _settings.Store.DefaultCentralSslStore; + path = CentralSsl.DefaultPath(_settings); } while (string.IsNullOrWhiteSpace(path) || !path.ValidPath(_log)) { @@ -36,36 +36,36 @@ namespace PKISharp.WACS.Plugins.StorePlugins } // Get password from command line, default setting or user input - var password = args.PfxPassword; + var password = args?.PfxPassword; if (string.IsNullOrWhiteSpace(password)) { - password = _settings.Store.DefaultCentralSslPfxPassword; + password = CentralSsl.DefaultPassword(_settings); } if (string.IsNullOrEmpty(password)) { - password = await input.ReadPassword("Password to use for the PFX files, or enter for none"); + password = await input.ReadPassword("Password to use for the .pfx files, or <Enter> for none"); } - return Create(path, password, args.KeepExisting); + return Create(path, password, args?.KeepExisting ?? false); } public override async Task<CentralSslOptions?> Default() { var args = _arguments.GetArguments<CentralSslArguments>(); - var path = _settings.Store.DefaultCentralSslStore; + var path = CentralSsl.DefaultPath(_settings); if (string.IsNullOrWhiteSpace(path)) { - path = _arguments.TryGetRequiredArgument(nameof(args.CentralSslStore), args.CentralSslStore); + path = _arguments.TryGetRequiredArgument(nameof(args.CentralSslStore), args?.CentralSslStore); } - var password = _settings.Store.DefaultCentralSslPfxPassword; - if (!string.IsNullOrWhiteSpace(args.PfxPassword)) + var password = CentralSsl.DefaultPassword(_settings); + if (!string.IsNullOrWhiteSpace(args?.PfxPassword)) { password = args.PfxPassword; } if (path != null && path.ValidPath(_log)) { - return Create(path, password, args.KeepExisting); + return Create(path, password, args?.KeepExisting ?? false); } else { @@ -79,11 +79,11 @@ namespace PKISharp.WACS.Plugins.StorePlugins { KeepExisting = keepExisting }; - if (!string.IsNullOrWhiteSpace(password) && !string.Equals(password, _settings.Store.DefaultCentralSslPfxPassword)) + if (!string.IsNullOrWhiteSpace(password) && !string.Equals(password, CentralSsl.DefaultPassword(_settings))) { ret.PfxPassword = new ProtectedString(password); } - if (!string.Equals(path, _settings.Store.DefaultCentralSslStore, StringComparison.CurrentCultureIgnoreCase)) + if (!string.Equals(path, CentralSsl.DefaultPath(_settings), StringComparison.CurrentCultureIgnoreCase)) { ret.Path = path; } diff --git a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs index 9aa1521..6d2be30 100644 --- a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs +++ b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs @@ -48,7 +48,11 @@ namespace PKISharp.WACS.Plugins.StorePlugins // First priority: specified in the parameters _storeName = _options.StoreName; - // Second priority: specified in the .config + // Second priority: specified in settings.json + if (string.IsNullOrEmpty(_storeName)) + { + _storeName = _settings.Store.CertificateStore?.DefaultStore; + } if (string.IsNullOrEmpty(_storeName)) { _storeName = _settings.Store.DefaultCertificateStore; diff --git a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStoreOptionsFactory.cs b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStoreOptionsFactory.cs index de469fa..d534a05 100644 --- a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStoreOptionsFactory.cs +++ b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStoreOptionsFactory.cs @@ -21,9 +21,9 @@ namespace PKISharp.WACS.Plugins.StorePlugins { var args = _arguments.GetArguments<CertificateStoreArguments>(); var ret = new CertificateStoreOptions { - StoreName = args.CertificateStore, - KeepExisting = args.KeepExisting, - AclFullControl = args.AclFullControl.ParseCsv() + StoreName = args?.CertificateStore, + KeepExisting = args?.KeepExisting ?? false, + AclFullControl = args?.AclFullControl.ParseCsv() }; return ret; } diff --git a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs index ffd851a..97f1d86 100644 --- a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs +++ b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs @@ -18,23 +18,35 @@ namespace PKISharp.WACS.Plugins.StorePlugins private readonly string _path; + public static string? DefaultPath(ISettingsService settings) + { + var ret = settings.Store.PemFiles?.DefaultPath; + if (string.IsNullOrWhiteSpace(ret)) + { + ret = settings.Store.DefaultPemFilesPath; + } + return ret; + } + public PemFiles( ILogService log, ISettingsService settings, PemService pemService, PemFilesOptions options) { _log = log; _pemService = pemService; - var path = !string.IsNullOrWhiteSpace(options.Path) ? - options.Path : - settings.Store.DefaultPemFilesPath; - if (path != null && path.ValidPath(log)) + var path = options.Path; + if (string.IsNullOrWhiteSpace(path)) { - _log.Debug("Using .pem certificate path: {path}", path); + path = DefaultPath(settings); + } + if (!string.IsNullOrWhiteSpace(path) && path.ValidPath(log)) + { + _log.Debug("Using .pem files path: {path}", path); _path = path; } else { - throw new Exception($"Specified PemFiles path {path} is not valid."); + throw new Exception($"Specified .pem files path {path} is not valid."); } } @@ -66,12 +78,15 @@ namespace PKISharp.WACS.Plugins.StorePlugins // Save complete chain File.WriteAllText(Path.Combine(_path, $"{name}-chain.pem"), exportString); - input.StoreInfo.Add(GetType(), - new StoreInfo() - { - Name = PemFilesOptions.PluginName, - Path = _path - }); + if (!input.StoreInfo.ContainsKey(GetType())) + { + input.StoreInfo.Add(GetType(), + new StoreInfo() + { + Name = PemFilesOptions.PluginName, + Path = _path + }); + } // Private key if (input.CacheFile != null) diff --git a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFilesOptionsFactory.cs b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFilesOptionsFactory.cs index 02a26cf..359276a 100644 --- a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFilesOptionsFactory.cs +++ b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFilesOptionsFactory.cs @@ -22,10 +22,10 @@ namespace PKISharp.WACS.Plugins.StorePlugins public override async Task<PemFilesOptions?> Aquire(IInputService input, RunLevel runLevel) { var args = _arguments.GetArguments<PemFilesArguments>(); - var path = args.PemFilesPath; + var path = args?.PemFilesPath; if (string.IsNullOrWhiteSpace(path)) { - path = _settings.Store.DefaultPemFilesPath; + path = PemFiles.DefaultPath(_settings); } while (string.IsNullOrWhiteSpace(path) || !path.ValidPath(_log)) { @@ -37,10 +37,10 @@ namespace PKISharp.WACS.Plugins.StorePlugins public override async Task<PemFilesOptions?> Default() { var args = _arguments.GetArguments<PemFilesArguments>(); - var path = _settings.Store.DefaultPemFilesPath; + var path = PemFiles.DefaultPath(_settings); if (string.IsNullOrWhiteSpace(path)) { - path = _arguments.TryGetRequiredArgument(nameof(args.PemFilesPath), args.PemFilesPath); + path = _arguments.TryGetRequiredArgument(nameof(args.PemFilesPath), args?.PemFilesPath); } if (path.ValidPath(_log)) { @@ -55,7 +55,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins private PemFilesOptions Create(string path) { var ret = new PemFilesOptions(); - if (!string.Equals(path, _settings.Store.DefaultPemFilesPath, StringComparison.CurrentCultureIgnoreCase)) + if (!string.Equals(path, PemFiles.DefaultPath(_settings), StringComparison.CurrentCultureIgnoreCase)) { ret.Path = path; } diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs new file mode 100644 index 0000000..8636eca --- /dev/null +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs @@ -0,0 +1,73 @@ +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Extensions; +using PKISharp.WACS.Plugins.Interfaces; +using PKISharp.WACS.Services; +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; + +namespace PKISharp.WACS.Plugins.StorePlugins +{ + internal class PfxFile : IStorePlugin + { + private readonly ILogService _log; + private readonly string _path; + private readonly string? _password; + + public PfxFile(ILogService log, ISettingsService settings, PfxFileOptions options) + { + _log = log; + + _password = !string.IsNullOrWhiteSpace(options.PfxPassword?.Value) ? + options.PfxPassword.Value : + settings.Store.PfxFile?.DefaultPassword; + + var path = !string.IsNullOrWhiteSpace(options.Path) ? + options.Path : + settings.Store.PfxFile?.DefaultPath; + + if (path != null && path.ValidPath(log)) + { + _path = path; + _log.Debug("Using pfx file path: {_path}", _path); + } + else + { + throw new Exception($"Specified pfx file path {path} is not valid."); + } + } + + private string PathForIdentifier(string identifier) => Path.Combine(_path, $"{identifier.Replace("*", "_")}.pfx"); + + public Task Save(CertificateInfo input) + { + _log.Information("Copying certificate to the pfx folder"); + var dest = PathForIdentifier(input.CommonName); + try + { + var collection = new X509Certificate2Collection + { + input.Certificate + }; + collection.AddRange(input.Chain.ToArray()); + File.WriteAllBytes(dest, collection.Export(X509ContentType.Pfx, _password)); + } + catch (Exception ex) + { + _log.Error(ex, "Error copying certificate to pfx path"); + } + input.StoreInfo.Add(GetType(), + new StoreInfo() + { + Name = PfxFileOptions.PluginName, + Path = _path + }); + return Task.CompletedTask; + } + + public Task Delete(CertificateInfo input) => Task.CompletedTask; + + (bool, string?) IPlugin.Disabled => (false, null); + } +} diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArguments.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArguments.cs new file mode 100644 index 0000000..f3ddcbb --- /dev/null +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArguments.cs @@ -0,0 +1,8 @@ +namespace PKISharp.WACS.Plugins.StorePlugins +{ + internal class PfxFileArguments + { + public string? PfxFilePath { get; set; } + public string? PfxPassword { get; set; } + } +} diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArgumentsProvider.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArgumentsProvider.cs new file mode 100644 index 0000000..8fac2d0 --- /dev/null +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileArgumentsProvider.cs @@ -0,0 +1,22 @@ +using Fclp; +using PKISharp.WACS.Configuration; + +namespace PKISharp.WACS.Plugins.StorePlugins +{ + internal class PfxFileArgumentsProvider : BaseArgumentsProvider<PfxFileArguments> + { + public override string Name => "PFX file plugin"; + public override string Group => "Store"; + public override string Condition => "--store pfxfile"; + + public override void Configure(FluentCommandLineParser<PfxFileArguments> parser) + { + parser.Setup(o => o.PfxFilePath) + .As("pfxfilepath") + .WithDescription("Path to write the .pfx file to."); + parser.Setup(o => o.PfxPassword) + .As("pfxpassword") + .WithDescription("Password to set for .pfx files exported to the IIS CSS."); + } + } +} diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptions.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptions.cs new file mode 100644 index 0000000..aed5bb2 --- /dev/null +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptions.cs @@ -0,0 +1,36 @@ +using PKISharp.WACS.Plugins.Base; +using PKISharp.WACS.Plugins.Base.Options; +using PKISharp.WACS.Services; +using PKISharp.WACS.Services.Serialization; + +namespace PKISharp.WACS.Plugins.StorePlugins +{ + [Plugin("2a2c576f-7637-4ade-b8db-e8613b0bb33e")] + internal class PfxFileOptions : StorePluginOptions<PfxFile> + { + /// <summary> + /// Path to the folder + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// PfxFile password + /// </summary> + public ProtectedString? PfxPassword { get; set; } + + internal const string PluginName = "PfxFile"; + public override string Name => PluginName; + public override string Description => "PFX archive"; + + /// <summary> + /// Show details to the user + /// </summary> + /// <param name="input"></param> + public override void Show(IInputService input) + { + base.Show(input); + input.Show("Path", string.IsNullOrEmpty(Path) ? "[Default from settings.json]" : Path, level: 2); + input.Show("Password", string.IsNullOrEmpty(PfxPassword?.Value) ? "[Default from settings.json]" : new string('*', PfxPassword.Value.Length), level: 2); + } + } +} diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs new file mode 100644 index 0000000..00359b4 --- /dev/null +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFileOptionsFactory.cs @@ -0,0 +1,91 @@ +using PKISharp.WACS.Extensions; +using PKISharp.WACS.Plugins.Base.Factories; +using PKISharp.WACS.Services; +using PKISharp.WACS.Services.Serialization; +using System; +using System.Threading.Tasks; + +namespace PKISharp.WACS.Plugins.StorePlugins +{ + internal class PfxFileOptionsFactory : StorePluginOptionsFactory<PfxFile, PfxFileOptions> + { + private readonly ILogService _log; + private readonly IArgumentsService _arguments; + private readonly ISettingsService _settings; + + public PfxFileOptionsFactory(ILogService log, ISettingsService settings, IArgumentsService arguments) + { + _log = log; + _arguments = arguments; + _settings = settings; + } + + public override async Task<PfxFileOptions?> Aquire(IInputService input, RunLevel runLevel) + { + var args = _arguments.GetArguments<PfxFileArguments>(); + + // Get path from command line, default setting or user input + var path = args?.PfxFilePath; + if (string.IsNullOrWhiteSpace(path)) + { + path = _settings.Store.PfxFile?.DefaultPath; + } + while (string.IsNullOrWhiteSpace(path) || !path.ValidPath(_log)) + { + path = await input.RequestString("Path to folder to store the .pfx file"); + } + + // Get password from command line, default setting or user input + var password = args?.PfxPassword; + if (string.IsNullOrWhiteSpace(password)) + { + password = _settings.Store.PfxFile?.DefaultPassword; + } + if (string.IsNullOrEmpty(password)) + { + password = await input.ReadPassword("Password to use for the .pfx files or <Enter> for none"); + } + return Create(path, password); + } + + public override async Task<PfxFileOptions?> Default() + { + var args = _arguments.GetArguments<PfxFileArguments>(); + var path = _settings.Store.PfxFile?.DefaultPath; + if (string.IsNullOrWhiteSpace(path)) + { + path = _arguments.TryGetRequiredArgument(nameof(args.PfxFilePath), args?.PfxFilePath); + } + + var password = _settings.Store.PfxFile?.DefaultPassword; + if (!string.IsNullOrWhiteSpace(args?.PfxPassword)) + { + password = args.PfxPassword; + } + + if (path != null && path.ValidPath(_log)) + { + return Create(path, password); + } + else + { + throw new Exception("Invalid path specified"); + } + } + + private PfxFileOptions Create(string path, string? password) + { + var ret = new PfxFileOptions(); + if (!string.IsNullOrWhiteSpace(password) && + !string.Equals(password, _settings.Store.PfxFile?.DefaultPassword)) + { + ret.PfxPassword = new ProtectedString(password); + } + if (!string.Equals(path, _settings.Store.PfxFile?.DefaultPath, StringComparison.CurrentCultureIgnoreCase)) + { + ret.Path = path; + } + return ret; + } + } +} diff --git a/src/main.lib/Plugins/TargetPlugins/Csr/CsrOptionsFactory.cs b/src/main.lib/Plugins/TargetPlugins/Csr/CsrOptionsFactory.cs index a51ac09..b631528 100644 --- a/src/main.lib/Plugins/TargetPlugins/Csr/CsrOptionsFactory.cs +++ b/src/main.lib/Plugins/TargetPlugins/Csr/CsrOptionsFactory.cs @@ -25,7 +25,7 @@ namespace PKISharp.WACS.Plugins.TargetPlugins do { ret.CsrFile = await _arguments.TryGetArgument( - args.CsrFile, + args?.CsrFile, inputService, "Enter the path to the CSR"); } @@ -34,9 +34,9 @@ namespace PKISharp.WACS.Plugins.TargetPlugins string? pkFile; do { - pkFile = await _arguments.TryGetArgument(args.CsrFile, + pkFile = await _arguments.TryGetArgument(args?.CsrFile, inputService, - "Enter the path to the corresponding private key, or <ENTER> to create a certificate without one"); + "Enter the path to the corresponding private key, or <Enter> to create a certificate without one"); } while (!(string.IsNullOrWhiteSpace(pkFile) || pkFile.ValidFile(_log))); @@ -51,11 +51,11 @@ namespace PKISharp.WACS.Plugins.TargetPlugins public override async Task<CsrOptions?> Default() { var args = _arguments.GetArguments<CsrArguments>(); - if (!args.CsrFile.ValidFile(_log)) + if (!args?.CsrFile.ValidFile(_log) ?? false) { return null; } - if (!string.IsNullOrEmpty(args.PkFile)) + if (!string.IsNullOrEmpty(args?.PkFile)) { if (!args.PkFile.ValidFile(_log)) { @@ -64,8 +64,8 @@ namespace PKISharp.WACS.Plugins.TargetPlugins } return new CsrOptions() { - CsrFile = args.CsrFile, - PkFile = string.IsNullOrWhiteSpace(args.PkFile) ? null : args.PkFile + CsrFile = args?.CsrFile, + PkFile = string.IsNullOrWhiteSpace(args?.PkFile) ? null : args.PkFile }; } } diff --git a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptions.cs b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptions.cs index dea2ec8..3facd2d 100644 --- a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptions.cs +++ b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptions.cs @@ -11,7 +11,7 @@ namespace PKISharp.WACS.Plugins.TargetPlugins internal class IISOptions : TargetPluginOptions<IIS> { public override string Name => "IIS"; - public override string Description => "IIS"; + public override string Description => "Read site bindings from IIS"; /// <summary> /// Common name for the certificate diff --git a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs index a1afe1e..1ce1d1c 100644 --- a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs +++ b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs @@ -91,16 +91,11 @@ namespace PKISharp.WACS.Plugins.TargetPlugins { var allBindings = _iisHelper.GetBindings(); var visibleBindings = allBindings.Where(x => !_arguments.MainArguments.HideHttps || x.Https == false).ToList(); - if (!runLevel.HasFlag(RunLevel.Advanced)) - { - // Hide bindings with wildcards because they cannot be validated in simple mode - visibleBindings = visibleBindings.Where(x => !x.Wildcard).ToList(); - } var ret = await TryAquireSettings(input, allBindings, visibleBindings, allSites, visibleSites, runLevel); if (ret != null) { var filtered = _iisHelper.FilterBindings(allBindings, ret); - await ListBindings(input, runLevel, filtered, ret.CommonName); + await ListBindings(input, filtered, ret.CommonName); if (await input.PromptYesNo("Continue with this selection?", true)) { return ret; @@ -132,9 +127,10 @@ namespace PKISharp.WACS.Plugins.TargetPlugins List<IISHelper.IISSiteOption> visibleSites, RunLevel runLevel) { + input.CreateSpace(); input.Show(null, "Please select which website(s) should be scanned for host names. " + "You may input one or more site identifiers (comma separated) to filter by those sites, " + - "or alternatively leave the input empty to scan *all* websites.", true); + "or alternatively leave the input empty to scan *all* websites."); var options = new IISOptions(); await input.WritePagedList( @@ -143,63 +139,62 @@ namespace PKISharp.WACS.Plugins.TargetPlugins description: $"{x.Name} ({x.Hosts.Count()} binding{(x.Hosts.Count() == 1 ? "" : "s")})", command: x.Id.ToString(), color: x.Https ? ConsoleColor.DarkGray : (ConsoleColor?)null))); - var raw = await input.RequestString("Site identifier(s) or <ENTER> to choose all"); + var raw = await input.RequestString("Site identifier(s) or <Enter> to choose all"); if (!ParseSiteOptions(raw, allSites, options)) { return null; } var filtered = _iisHelper.FilterBindings(visibleBindings, options); - await ListBindings(input, runLevel, filtered); - input.Show(null, - "You may either choose to include all listed bindings as host names in your certificate, " + - "or apply an additional filter. Different types of filters are available.", true); + await ListBindings(input, filtered); + input.CreateSpace(); + input.Show(null, + "Listed above are the bindings found on the selected site(s). By default all of " + + "them will be included, but you may either pick specific ones by typing the host names " + + "or identifiers (comma seperated) or filter them using one of the options from the " + + "menu."); var askExclude = true; var filters = new List<Choice<Func<Task>>> { Choice.Create<Func<Task>>(() => { - askExclude = false; - return InputHosts( - "Include bindings", input, allBindings, filtered, options, - () => options.IncludeHosts, x => options.IncludeHosts = x); - }, "Pick specific bindings from the list"), - Choice.Create<Func<Task>>(() => { return InputPattern(input, options); - }, "Pick bindings based on a search pattern"), + }, "Pick bindings based on a search pattern", command: "P"), Choice.Create<Func<Task>>(() => { askExclude = false; return Task.CompletedTask; - }, "Pick *all* bindings", @default: true) + }, "Pick *all* bindings", @default: true, command: "A") }; if (runLevel.HasFlag(RunLevel.Advanced)) { - filters.Insert(2, Choice.Create<Func<Task>>(() => { + filters.Insert(1, Choice.Create<Func<Task>>(() => { askExclude = true; return InputRegex(input, options); - }, "Pick bindings based on a regular expression")); + }, "Pick bindings based on a regular expression", command: "R")); } - var chosen = await input.ChooseFromMenu("How do you want to pick the bindings?", filters); + var chosen = await input.ChooseFromMenu( + "Binding identifiers(s) or menu option", + filters, + (unknown) => + { + return Choice.Create<Func<Task>>(() => + { + askExclude = false; + return ProcessInputHosts( + unknown, allBindings, filtered, options, + () => options.IncludeHosts, x => options.IncludeHosts = x); + }); + }); await chosen.Invoke(); filtered = _iisHelper.FilterBindings(allBindings, options); - // Check for wildcards in simple mode - if (!runLevel.HasFlag(RunLevel.Advanced) && filtered.Any(x => x.Wildcard)) - { - await ListBindings(input, runLevel, filtered); - input.Show(null, "The pattern that you've chosen matches a wildcard binding, which " + - "is not supported by the 'simple' mode of this program because it requires DNS " + - "validation. Please try again with a different pattern or use the 'full options' " + - "mode instead.", true); - return null; - } - // Exclude specific bindings if (askExclude && filtered.Count > 1 && runLevel.HasFlag(RunLevel.Advanced)) { - await ListBindings(input, runLevel, filtered); + await ListBindings(input, filtered); + input.CreateSpace(); input.Show(null, "The listed bindings match your current filter settings. " + "If you wish to exclude one or more of them from the certificate, please " + - "input those bindings now. Press <Enter> to include all listed bindings.", true); + "input those bindings now. Press <Enter> to include all listed bindings."); await InputHosts("Exclude bindings", input, allBindings, filtered, options, () => options.ExcludeHosts, x => options.ExcludeHosts = x); @@ -238,28 +233,51 @@ namespace PKISharp.WACS.Plugins.TargetPlugins Func<List<string>?> get, Action<List<string>> set) { - var sorted = SortBindings(filtered).ToList(); - var raw = default(string); + string? raw; do { raw = await input.RequestString(label); - if (!string.IsNullOrEmpty(raw)) + } + while (!await ProcessInputHosts(raw, allBindings, filtered, options, get, set)); + } + + /// <summary> + /// Allows users to input both the full host name, or the number that + /// it's referred to by in the displayed list + /// </summary> + /// <param name="label"></param> + /// <param name="input"></param> + /// <param name="allBindings"></param> + /// <param name="filtered"></param> + /// <param name="options"></param> + /// <param name="get"></param> + /// <param name="set"></param> + /// <returns></returns> + async Task<bool> ProcessInputHosts( + string raw, + List<IISHelper.IISBindingOption> allBindings, + List<IISHelper.IISBindingOption> filtered, + IISOptions options, + Func<List<string>?> get, + Action<List<string>> set) + { + var sorted = SortBindings(filtered).ToList(); + if (!string.IsNullOrEmpty(raw)) + { + // Magically replace binding identifiers by their proper host names + raw = string.Join(",", raw.ParseCsv().Select(x => { - // Magically replace binding identifiers by their proper host names - raw = string.Join(",", raw.ParseCsv().Select(x => + if (int.TryParse(x, out var id)) { - if (int.TryParse(x, out var id)) + if (id > 0 && id <= sorted.Count()) { - if (id > 0 && id <= sorted.Count()) - { - return sorted[id - 1].HostUnicode; - } + return sorted[id - 1].HostUnicode; } - return x; - })); - } + } + return x; + })); } - while (!ParseHostOptions(raw, allBindings, options, get, set)); + return ParseHostOptions(raw, allBindings, options, get, set); } async Task InputCommonName(IInputService input, List<IISHelper.IISBindingOption> filtered, IISOptions options) @@ -282,7 +300,8 @@ namespace PKISharp.WACS.Plugins.TargetPlugins /// <returns></returns> async Task InputPattern(IInputService input, IISOptions options) { - input.Show(null, IISArgumentsProvider.PatternExamples, true); + input.CreateSpace(); + input.Show(null, IISArgumentsProvider.PatternExamples); string raw; do { @@ -358,22 +377,18 @@ namespace PKISharp.WACS.Plugins.TargetPlugins /// <param name="bindings"></param> /// <param name="highlight"></param> /// <returns></returns> - private async Task ListBindings(IInputService input, RunLevel runLevel, List<IISHelper.IISBindingOption> bindings, string? highlight = null) + private async Task ListBindings(IInputService input, List<IISHelper.IISBindingOption> bindings, string? highlight = null) { var sortedBindings = SortBindings(bindings); await input.WritePagedList( sortedBindings.Select(x => Choice.Create( item: x, - color: BindingColor(x, runLevel, highlight)))); + color: BindingColor(x, highlight)))); } - private ConsoleColor? BindingColor(IISHelper.IISBindingOption binding, RunLevel runLevel, string? highlight = null) + private ConsoleColor? BindingColor(IISHelper.IISBindingOption binding, string? highlight = null) { - if (!runLevel.HasFlag(RunLevel.Advanced) && binding.Wildcard) - { - return ConsoleColor.Red; - } - else if (binding.HostUnicode == highlight) + if (binding.HostUnicode == highlight) { return ConsoleColor.Green; } @@ -406,7 +421,7 @@ namespace PKISharp.WACS.Plugins.TargetPlugins // be explicit about it. _log.Error("You have not specified any filters. If you are sure that you want " + "to create a certificate for *all* bindings on the server, please specific " + - "-siteid s"); + "--siteid s"); return null; } diff --git a/src/main.lib/Plugins/TargetPlugins/Manual/ManualOptionsFactory.cs b/src/main.lib/Plugins/TargetPlugins/Manual/ManualOptionsFactory.cs index 363f839..2588b1e 100644 --- a/src/main.lib/Plugins/TargetPlugins/Manual/ManualOptionsFactory.cs +++ b/src/main.lib/Plugins/TargetPlugins/Manual/ManualOptionsFactory.cs @@ -27,11 +27,11 @@ namespace PKISharp.WACS.Plugins.TargetPlugins public override async Task<ManualOptions?> Default() { var args = _arguments.GetArguments<ManualArguments>(); - var input = _arguments.TryGetRequiredArgument(nameof(args.Host), args.Host); + var input = _arguments.TryGetRequiredArgument(nameof(args.Host), args?.Host); var ret = Create(input); if (ret != null) { - var commonName = args.CommonName; + var commonName = args?.CommonName; if (!string.IsNullOrWhiteSpace(commonName)) { commonName = commonName.ToLower().Trim().ConvertPunycode(); diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/Acme.cs index aa03b6e..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 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)); - 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/Acme/AcmeOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/AcmeOptionsFactory.cs index b1c2402..35c6390 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/AcmeOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Acme/AcmeOptionsFactory.cs @@ -70,7 +70,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { var baseUriRaw = _arguments.TryGetRequiredArgument(nameof(AcmeArguments.AcmeDnsServer), - _arguments.GetArguments<AcmeArguments>().AcmeDnsServer); + _arguments.GetArguments<AcmeArguments>()?.AcmeDnsServer); if (!string.IsNullOrEmpty(baseUriRaw)) { baseUri = new Uri(baseUriRaw); diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs index 7b8d1c8..267aab4 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs @@ -1,153 +1,273 @@ -using ACMESharp.Authorizations; -using PKISharp.WACS.Clients.DNS; -using PKISharp.WACS.Services; -using Serilog.Context; -using System; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Plugins.ValidationPlugins -{ - /// <summary> - /// Base implementation for DNS-01 validation plugins - /// </summary>reee - public abstract class DnsValidation<TPlugin> : Validation<Dns01ChallengeValidationDetails> - { - protected readonly LookupClientProvider _dnsClient; - protected readonly ILogService _log; - protected readonly ISettingsService _settings; - private string? _recordName; - - protected DnsValidation( - LookupClientProvider dnsClient, - ILogService log, - ISettingsService settings) - { - _dnsClient = dnsClient; - _log = log; - _settings = settings; - } - - public override async Task PrepareChallenge() - { - // Check for substitute domains - if (_settings.Validation.AllowDnsSubstitution) - { - try - { - // Resolve CNAME in DNS - var client = await _dnsClient.GetClients(Challenge.DnsRecordName); - var (_, cname) = await client.First().GetTextRecordValues(Challenge.DnsRecordName, 0); - - // Normalize CNAME - var idn = new IdnMapping(); - cname = cname.ToLower().Trim().TrimEnd('.'); - cname = idn.GetAscii(cname); - - // Substitute - if (cname != Challenge.DnsRecordName) - { - _log.Information("Detected that {DnsRecordName} is a CNAME that leads to {cname}", Challenge.DnsRecordName, cname); - _recordName = cname; - } - } - catch (Exception ex) - { - _log.Debug("Error checking for substitute domains: {ex}", ex.Message); - } - } - - // Create record - await CreateRecord(_recordName ?? Challenge.DnsRecordName, Challenge.DnsRecordValue); - _log.Information("Answer should now be available at {answerUri}", _recordName ?? Challenge.DnsRecordName); - - // 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 (await PreValidate(retry)) - { - 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); - } - } - } - } - - protected async Task<bool> PreValidate(int attempt) - { - try - { - var dnsClients = await _dnsClient.GetClients(Challenge.DnsRecordName, attempt); - _log.Debug("Looking for TXT value {DnsRecordValue}...", Challenge.DnsRecordValue); - foreach (var client in dnsClients) - { - _log.Debug("Preliminary validation starting from {ip}...", client.IpAddress); - var (answers, server) = await client.GetTextRecordValues(Challenge.DnsRecordName, attempt); - _log.Debug("Preliminary validation retrieved answers from {server}", server); - if (!answers.Any()) - { - _log.Warning("Preliminary validation failed: no TXT records found"); - return false; - } - if (!answers.Contains(Challenge.DnsRecordValue)) - { - _log.Debug("Preliminary validation found values: {answers}", answers); - _log.Warning("Preliminary validation failed: incorrect TXT record(s) found"); - return false; - } - _log.Debug("Preliminary validation from {ip} looks good", client.IpAddress); - } - } - catch (Exception ex) - { - _log.Error(ex, "Preliminary validation failed"); - return false; - } - _log.Information("Preliminary validation succeeded"); - return true; - } - - /// <summary> - /// Delete record when we're done - /// </summary> - public override async Task CleanUp() - { - if (HasChallenge) - { - await DeleteRecord(_recordName ?? Challenge.DnsRecordName, Challenge.DnsRecordValue);; - } - } - - /// <summary> - /// Delete validation record - /// </summary> - /// <param name="recordName">Name of the record</param> - public abstract Task DeleteRecord(string recordName, string token); - - /// <summary> - /// Create validation record - /// </summary> - /// <param name="recordName">Name of the record</param> - /// <param name="token">Contents of the record</param> - public abstract Task CreateRecord(string recordName, string token); - - } -} +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;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins
+{
+ /// <summary>
+ /// Base implementation for DNS-01 validation plugins
+ /// </summary>reee
+ public abstract class DnsValidation<TPlugin> : Validation<Dns01ChallengeValidationDetails>
+ {
+ protected readonly LookupClientProvider _dnsClient;
+ protected readonly ILogService _log;
+ protected readonly ISettingsService _settings;
+ private readonly List<DnsValidationRecord> _recordsCreated = new List<DnsValidationRecord>();
+
+ protected DnsValidation(
+ LookupClientProvider dnsClient,
+ ILogService log,
+ ISettingsService settings)
+ {
+ _dnsClient = dnsClient;
+ _log = log;
+ _settings = settings;
+ }
+
+ /// <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
+ var authority = await _dnsClient.GetAuthority(
+ challenge.DnsRecordName,
+ followCnames: _settings.Validation.AllowDnsSubstitution);
+
+ var success = false;
+ while (!success)
+ {
+ var record = new DnsValidationRecord(context, authority, challenge.DnsRecordValue);
+ success = await CreateRecord(record);
+ if (!success)
+ {
+ if (authority.From == null)
+ {
+ throw new Exception("Unable to prepare for challenge answer");
+ }
+ else
+ {
+ 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
+ if (_settings.Validation.PreValidateDns)
+ {
+ var validationTasks = _recordsCreated.Select(r => ValidateRecord(r));
+ await Task.WhenAll(validationTasks);
+ }
+ }
+
+ /// <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
+ {
+ _log.Debug("[{identifier}] Looking for TXT value {DnsRecordValue}...", record.Context.Identifier, record.Authority.Domain);
+ foreach (var client in record.Authority.Nameservers)
+ {
+ _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("[{identifier}] Preliminary validation failed: no TXT records found", record.Context.Identifier);
+ return false;
+ }
+ if (!answers.Contains(record.Value))
+ {
+ _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("[{identifier}] Preliminary validation from {ip} looks good", record.Context.Identifier, client.IpAddress);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "[{identifier}] Preliminary validation failed", record.Context.Identifier);
+ return false;
+ }
+ _log.Information("[{identifier}] Preliminary validation succeeded", record.Context.Identifier);
+ return true;
+ }
+
+ /// <summary>
+ /// Delete record when we're done
+ /// </summary>
+ public override sealed async Task CleanUp()
+ {
+ foreach (var record in _recordsCreated)
+ {
+ try
+ {
+ 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(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(DnsValidationRecord record);
+
+ /// <summary>
+ /// Match DNS zone to use from a list of all zones
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="candidates"></param>
+ /// <param name="recordName"></param>
+ /// <returns></returns>
+ public T? FindBestMatch<T>(Dictionary<string, T> candidates, string recordName) where T: class
+ {
+ var result = candidates.Keys.Select(key =>
+ {
+ var fit = 0;
+ var name = key.TrimEnd('.');
+ if (string.Equals(recordName, name, StringComparison.InvariantCultureIgnoreCase) ||
+ recordName.EndsWith("." + name, StringComparison.InvariantCultureIgnoreCase))
+ {
+ // If there is a zone for a.b.c.com (4) and one for c.com (2)
+ // then the former is a better (more specific) match than the
+ // latter, so we should use that
+ fit = name.Split('.').Count();
+ _log.Verbose("Zone {name} scored {fit} points", key, fit);
+ }
+ else
+ {
+ _log.Verbose("Zone {name} not matched", key);
+ }
+ return new {
+ key,
+ value = candidates[key],
+ fit
+ };
+ }).
+ Where(x => x.fit > 0).
+ OrderByDescending(x => x.fit).
+ FirstOrDefault();
+
+ if (result != null)
+ {
+ _log.Debug("Picked {name} as best match", result.key);
+ return result.value;
+ }
+ else
+ {
+ _log.Error("No match found");
+ 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 317afe9..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,39 +9,42 @@ 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 CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { - _input.Show("Domain", _identifier, true); - _input.Show("Record", recordName); + _input.CreateSpace(); + _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."); - await _input.Wait("Please press <Enter> after you've created and verified the record"); + if (!await _input.Wait("Please press <Enter> after you've created and verified the record")) + { + _log.Warning("User aborted"); + return false; + } // Pre-pre-validate, allowing the manual user to correct mistakes while (true) { - if (await PreValidate(0)) + if (await PreValidate(record)) { - break; + return true; } else { @@ -50,18 +55,19 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns "try ACME validation anyway.", true); if (!retry) { - break; + return false; } } } } - public override Task DeleteRecord(string recordName, string token) + public override Task DeleteRecord(DnsValidationRecord record) { - _input.Show("Domain", _identifier, true); - _input.Show("Record", recordName); + _input.CreateSpace(); + _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 6b7b34b..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 CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { var script = _options.Script ?? _options.CreateScript; if (!string.IsNullOrWhiteSpace(script)) @@ -38,15 +37,24 @@ 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 { _log.Error("No create script configured"); + return false; } } - 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)) @@ -56,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 { @@ -64,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/Dns/Script/ScriptOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/ScriptOptionsFactory.cs index 3e668c4..c7031a1 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/Script/ScriptOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/Script/ScriptOptionsFactory.cs @@ -37,7 +37,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns string? createScript = null; do { - createScript = await _arguments.TryGetArgument(args.DnsCreateScript, input, "Path to script that creates DNS records"); + createScript = await _arguments.TryGetArgument(args?.DnsCreateScript, input, "Path to script that creates DNS records"); } while (!createScript.ValidFile(_log)); @@ -53,7 +53,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns Choice.Create<Func<Task>>(async () => { do { deleteScript = await _arguments.TryGetArgument( - args.DnsDeleteScript, + args?.DnsDeleteScript, input, "Path to script that deletes DNS records"); } @@ -68,12 +68,12 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns input.Show("{Identifier}", "Domain that's being validated"); input.Show("{RecordName}", "Full TXT record name"); input.Show("{Token}", "Expected value in the TXT record"); - var createArgs = await _arguments.TryGetArgument(args.DnsCreateScriptArguments, input, $"Input parameters for create script, or enter for default \"{Script.DefaultCreateArguments}\""); + var createArgs = await _arguments.TryGetArgument(args?.DnsCreateScriptArguments, input, $"Input parameters for create script, or enter for default \"{Script.DefaultCreateArguments}\""); string? deleteArgs = null; if (!string.IsNullOrWhiteSpace(ret.DeleteScript) || !string.IsNullOrWhiteSpace(ret.Script)) { - deleteArgs = await _arguments.TryGetArgument(args.DnsDeleteScriptArguments, input, $"Input parameters for delete script, or enter for default \"{Script.DefaultDeleteArguments}\""); + deleteArgs = await _arguments.TryGetArgument(args?.DnsDeleteScriptArguments, input, $"Input parameters for delete script, or enter for default \"{Script.DefaultDeleteArguments}\""); } ProcessArgs(ret, createArgs, deleteArgs); return ret; @@ -83,7 +83,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { var args = _arguments.GetArguments<ScriptArguments>(); var ret = new ScriptOptions(); - ProcessScripts(ret, args.DnsScript, args.DnsCreateScript, args.DnsDeleteScript); + ProcessScripts(ret, args?.DnsScript, args?.DnsCreateScript, args?.DnsDeleteScript); if (!string.IsNullOrEmpty(ret.Script)) { if (!ret.Script.ValidFile(_log)) @@ -109,7 +109,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - ProcessArgs(ret, args.DnsCreateScriptArguments, args.DnsDeleteScriptArguments); + ProcessArgs(ret, args?.DnsCreateScriptArguments, args?.DnsDeleteScriptArguments); return ret; } 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/FileSystem/FileSystemOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystemOptionsFactory.cs index 19a8de3..9a4e748 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystemOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystemOptionsFactory.cs @@ -32,7 +32,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http if (target.IIS && _iisClient.HasWebSites) { - if (args.ValidationSiteId != null) + if (args?.ValidationSiteId != null) { // Throws exception when not found var site = _iisClient.GetWebSite(args.ValidationSiteId.Value); 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/HttpValidationOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationOptionsFactory.cs index f98a2e8..99583f4 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidationOptionsFactory.cs @@ -61,7 +61,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins var args = _arguments.GetArguments<HttpValidationArguments>(); if (string.IsNullOrEmpty(path) && !allowEmpty) { - path = _arguments.TryGetRequiredArgument(nameof(args.WebRoot), args.WebRoot); + path = _arguments.TryGetRequiredArgument(nameof(args.WebRoot), args?.WebRoot); } if (!string.IsNullOrEmpty(path) && !PathIsValid(path)) { @@ -70,7 +70,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins return new TOptions { Path = path, - CopyWebConfig = target.IIS || args.ManualTargetIsIIS + CopyWebConfig = target.IIS || (args?.ManualTargetIsIIS ?? false) }; } 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 2efacfa..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,41 +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); - try - { - var postfix = "/.well-known/acme-challenge/"; - var prefix = _options.Https == true ? - $"https://+:{_options.Port ?? DefaultHttpsValidationPort}{postfix}" : - $"http://+:{_options.Port ?? DefaultHttpValidationPort}{postfix}"; - 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 HttpListener, this may be because of insufficient rights or a non-Microsoft webserver using port 80"); - throw; + if (HasListener) + { + try + { + Listener.Stop(); + Listener.Close(); + } + finally + { + _listener = null; + } + } } + return Task.CompletedTask; } diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHostingOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHostingOptionsFactory.cs index 04bb38c..4db84e0 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHostingOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHostingOptionsFactory.cs @@ -26,8 +26,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http var args = _arguments.GetArguments<SelfHostingArguments>(); return new SelfHostingOptions() { - Port = args.ValidationPort, - Https = string.Equals(args.ValidationProtocol, "https", StringComparison.OrdinalIgnoreCase) ? true : (bool?)null + Port = args?.ValidationPort, + Https = string.Equals(args?.ValidationProtocol, "https", StringComparison.OrdinalIgnoreCase) ? true : (bool?)null }; } } 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/Tls/SelfHosting/SelfHostingOptionsFactory.cs b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHostingOptionsFactory.cs index 451cc19..1d1e436 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHostingOptionsFactory.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Tls/SelfHosting/SelfHostingOptionsFactory.cs @@ -27,7 +27,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Tls var args = _arguments.GetArguments<SelfHostingArguments>(); return new SelfHostingOptions() { - Port = args.ValidationPort + Port = args?.ValidationPort }; } } 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 ed82881..0210ca2 100644 --- a/src/main.lib/RenewalCreator.cs +++ b/src/main.lib/RenewalCreator.cs @@ -25,12 +25,14 @@ namespace PKISharp.WACS private readonly IAutofacBuilder _scopeBuilder; private readonly ExceptionHandler _exceptionHandler; private readonly RenewalExecutor _renewalExecution; + private readonly NotificationService _notification; public RenewalCreator( PasswordGenerator passwordGenerator, MainArguments args, IRenewalStore renewalStore, IContainer container, IInputService input, ILogService log, ISettingsService settings, IAutofacBuilder autofacBuilder, + NotificationService notification, ExceptionHandler exceptionHandler, RenewalExecutor renewalExecutor) { _passwordGenerator = passwordGenerator; @@ -43,6 +45,7 @@ namespace PKISharp.WACS _scopeBuilder = autofacBuilder; _exceptionHandler = exceptionHandler; _renewalExecution = renewalExecutor; + _notification = notification; } /// <summary> @@ -73,7 +76,8 @@ namespace PKISharp.WACS // it or create it side by side with the current one. if (runLevel.HasFlag(RunLevel.Interactive)) { - _input.Show("Existing renewal", existing.ToString(_input), true); + _input.CreateSpace(); + _input.Show("Existing renewal", existing.ToString(_input)); if (!await _input.PromptYesNo($"Overwrite?", true)) { return temp; @@ -154,7 +158,7 @@ namespace PKISharp.WACS } else if (runLevel.HasFlag(RunLevel.Advanced | RunLevel.Interactive)) { - var alt = await _input.RequestString($"Suggested friendly name '{initialTarget.FriendlyName}', press <ENTER> to accept or type an alternative"); + var alt = await _input.RequestString($"Suggested friendly name '{initialTarget.FriendlyName}', press <Enter> to accept or type an alternative"); if (!string.IsNullOrEmpty(alt)) { tempRenewal.FriendlyName = alt; @@ -359,11 +363,19 @@ 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 { - _renewalStore.Save(renewal, result); + try + { + _renewalStore.Save(renewal, result); + await _notification.NotifyCreated(renewal, _log.Lines); + } + catch (Exception ex) + { + _exceptionHandler.HandleException(ex); + } } } diff --git a/src/main.lib/RenewalExecutor.cs b/src/main.lib/RenewalExecutor.cs index 4dbedbe..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; @@ -68,6 +51,8 @@ namespace PKISharp.WACS /// <returns></returns> public async Task<RenewResult> HandleRenewal(Renewal renewal, RunLevel runLevel) { + _input.CreateSpace(); + _log.Reset(); using var ts = _scopeBuilder.Target(_container, renewal, runLevel); using var es = _scopeBuilder.Execution(ts, renewal, runLevel); // Generate the target @@ -75,23 +60,23 @@ namespace PKISharp.WACS var (disabled, disabledReason) = targetPlugin.Disabled; if (disabled) { - throw new Exception($"Target plugin is not available. {disabledReason}"); + return new RenewResult($"Target plugin is not available. {disabledReason}"); } var target = await targetPlugin.Generate(); if (target is INull) { - throw new Exception($"Target plugin did not generate a target"); + return new RenewResult($"Target plugin did not generate a target"); } - if (!target.IsValid(_log)) - { - throw new Exception($"Target plugin generated an invalid target"); + if (!target.IsValid(_log)) + { + return new RenewResult($"Target plugin generated an invalid target"); } // Check if our validation plugin is (still) up to the task var validationPlugin = es.Resolve<IValidationPluginOptionsFactory>(); if (!validationPlugin.CanValidate(target)) { - throw new Exception($"Validation plugin is unable to validate the target. A wildcard host was introduced into a HTTP validated renewal."); + return new RenewResult($"Validation plugin is unable to validate the target. A wildcard host was introduced into a HTTP validated renewal."); } // Create one or more orders based on the target @@ -99,7 +84,7 @@ namespace PKISharp.WACS var orders = orderPlugin.Split(renewal, target); if (orders == null || orders.Count() == 0) { - throw new Exception("Order plugin failed to create order(s)"); + return new RenewResult("Order plugin failed to create order(s)"); } _log.Verbose("Targeted convert into {n} order(s)", orders.Count()); @@ -187,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) @@ -198,50 +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); - } - } - - /// <summary> /// Steps to take on succesful (re)authorization /// </summary> /// <param name="partialTarget"></param> @@ -294,7 +235,8 @@ namespace PKISharp.WACS for (var i = 0; i < steps; i++) { var storeOptions = context.Renewal.StorePluginOptions[i]; - var storePlugin = (IStorePlugin)context.Scope.Resolve(storeOptions.Instance); + var storePlugin = (IStorePlugin)context.Scope.Resolve(storeOptions.Instance, + new TypedParameter(storeOptions.GetType(), storeOptions)); if (!(storePlugin is INull)) { if (steps > 1) @@ -388,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 66de9cd..af7ba9f 100644 --- a/src/main.lib/RenewalManager.cs +++ b/src/main.lib/RenewalManager.cs @@ -67,12 +67,12 @@ namespace PKISharp.WACS none ? "no renewals" : $"{selectedRenewals.Count()} of {originalSelection.Count()} {totalLabel}"; + _input.CreateSpace(); _input.Show(null, "Welcome to the renewal manager. Actions selected in the menu below will " + "be applied to the following list of renewals. You may filter the list to target " + "your action at a more specific set of renewals, or sort it to make it easier to " + - "find what you're looking for.", - true); + "find what you're looking for."); var displayRenewals = selectedRenewals; var displayLimited = !displayAll && selectedRenewals.Count() >= _settings.UI.PageSize; @@ -201,7 +201,7 @@ namespace PKISharp.WACS await RevokeCertificates(selectedRenewals); } }, - $"Revoke certificate for {selectionLabel}", "V", + $"Revoke certificate(s) for {selectionLabel}", "V", @disabled: (none, "No renewals selected."))); options.Add( Choice.Create<Func<Task>>( @@ -211,7 +211,8 @@ namespace PKISharp.WACS if (selectedRenewals.Count() > 1) { - _input.Show(null, $"Currently selected {selectedRenewals.Count()} of {originalSelection.Count()} {totalLabel}", true); + _input.CreateSpace(); + _input.Show(null, $"Currently selected {selectedRenewals.Count()} of {originalSelection.Count()} {totalLabel}"); } var chosen = await _input.ChooseFromMenu( "Choose an action or type numbers to select renewals", @@ -283,9 +284,10 @@ namespace PKISharp.WACS $"Select {host.Value.Count()} renewals covering host {host.Key}")); } } + _input.CreateSpace(); if (options.Count == 0) { - _input.Show(null, "Analysis didn't find any overlap between renewals.", first: true); + _input.Show(null, "Analysis didn't find any overlap between renewals."); return selectedRenewals; } else @@ -294,10 +296,9 @@ namespace PKISharp.WACS Choice.Create( selectedRenewals.ToList(), $"Back")); - _input.Show(null, "Analysis found some overlap between renewals. You can select the overlapping renewals from the menu.", first: true); + _input.Show(null, "Analysis found some overlap between renewals. You can select the overlapping renewals from the menu."); return await _input.ChooseFromMenu("Please choose from the menu", options); } - } /// <summary> @@ -395,7 +396,8 @@ namespace PKISharp.WACS /// <returns></returns> private async Task<IEnumerable<Renewal>> FilterRenewalsByFriendlyName(IEnumerable<Renewal> current) { - _input.Show(null, "Please input friendly name to filter renewals by. " + IISArgumentsProvider.PatternExamples, true); + _input.CreateSpace(); + _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()); @@ -464,7 +466,15 @@ namespace PKISharp.WACS WarnAboutRenewalArguments(); foreach (var renewal in renewals) { - await ProcessRenewal(renewal, runLevel); + try + { + await ProcessRenewal(renewal, runLevel); + } + catch (Exception ex) + { + _exceptionHandler.HandleException(ex, "Unhandled error processing renewal"); + continue; + } } } } @@ -484,18 +494,18 @@ namespace PKISharp.WACS _renewalStore.Save(renewal, result); if (result.Success) { - notification.NotifySuccess(runLevel, renewal); + await notification.NotifySuccess(renewal, _log.Lines); } else { - notification.NotifyFailure(runLevel, renewal, result.ErrorMessages); + await notification.NotifyFailure(runLevel, renewal, result.ErrorMessages, _log.Lines); } } } catch (Exception ex) { _exceptionHandler.HandleException(ex); - notification.NotifyFailure(runLevel, renewal, new List<string> { ex.Message }); + await notification.NotifyFailure(runLevel, renewal, new List<string> { ex.Message }, _log.Lines); } } @@ -521,7 +531,8 @@ namespace PKISharp.WACS { try { - _input.Show("Id", renewal.Id, true); + _input.CreateSpace(); + _input.Show("Id", renewal.Id); _input.Show("File", $"{renewal.Id}.renewal.json"); _input.Show("FriendlyName", string.IsNullOrEmpty(renewal.FriendlyName) ? $"[Auto] {renewal.LastFriendlyName}" : renewal.FriendlyName); _input.Show(".pfx password", renewal.PfxPassword?.Value); @@ -610,7 +621,7 @@ namespace PKISharp.WACS try { await cs.RevokeCertificate(renewal); - renewal.History.Add(new RenewResult("Certificate revoked")); + renewal.History.Add(new RenewResult("Certificate(s) revoked")); } catch (Exception ex) { diff --git a/src/main.lib/RenewalValidator.cs b/src/main.lib/RenewalValidator.cs new file mode 100644 index 0000000..704fa3e --- /dev/null +++ b/src/main.lib/RenewalValidator.cs @@ -0,0 +1,446 @@ +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) + { + 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/ArgumentsParser.cs b/src/main.lib/Services/ArgumentsParser.cs index 20529f0..ea82ebc 100644 --- a/src/main.lib/Services/ArgumentsParser.cs +++ b/src/main.lib/Services/ArgumentsParser.cs @@ -11,7 +11,7 @@ namespace PKISharp.WACS.Configuration private readonly string[] _args; private readonly IEnumerable<IArgumentsProvider> _providers; - public T GetArguments<T>() where T : class, new() + public T? GetArguments<T>() where T : class, new() { foreach (var provider in _providers) { @@ -52,14 +52,18 @@ namespace PKISharp.WACS.Configuration return false; } var mainProvider = _providers.OfType<IArgumentsProvider<MainArguments>>().First(); - if (mainProvider.Validate(_log, main, main)) + if (mainProvider.Validate(main, main)) { // Validate the others var others = _providers.Except(new[] { mainProvider }); foreach (var other in others) { var opt = other.GetResult(_args); - if (!other.Validate(_log, opt, main)) + if (opt == null) + { + return false; + } + if (!other.Validate(opt, main)) { return false; } @@ -84,7 +88,7 @@ namespace PKISharp.WACS.Configuration foreach (var other in others) { var opt = other.GetResult(_args); - if (other.Active(opt)) + if (opt != null && other.Active(opt)) { return true; } diff --git a/src/main.lib/Services/ArgumentsService.cs b/src/main.lib/Services/ArgumentsService.cs index 685ca55..2ec6022 100644 --- a/src/main.lib/Services/ArgumentsService.cs +++ b/src/main.lib/Services/ArgumentsService.cs @@ -17,6 +17,10 @@ namespace PKISharp.WACS.Services if (_mainArguments == null) { _mainArguments = _parser.GetArguments<MainArguments>(); + if (_mainArguments == null) + { + _mainArguments = new MainArguments(); + } } return _mainArguments; } @@ -28,7 +32,7 @@ namespace PKISharp.WACS.Services _parser = parser; } - public T GetArguments<T>() where T : class, new() => _parser.GetArguments<T>(); + public T? GetArguments<T>() where T : class, new() => _parser.GetArguments<T>(); public async Task<string?> TryGetArgument(string? providedValue, IInputService input, string what, bool secret = false) => await TryGetArgument(providedValue, input, new[] { what }, secret); 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/CertificateService.cs b/src/main.lib/Services/CertificateService.cs index eca6861..949c9f5 100644 --- a/src/main.lib/Services/CertificateService.cs +++ b/src/main.lib/Services/CertificateService.cs @@ -1,529 +1,536 @@ -using ACMESharp.Protocol; -using Newtonsoft.Json; -using PKISharp.WACS.Clients.Acme; -using PKISharp.WACS.Configuration; -using PKISharp.WACS.DomainObjects; -using PKISharp.WACS.Extensions; -using PKISharp.WACS.Plugins.Interfaces; -using PKISharp.WACS.Services.Serialization; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading.Tasks; -using bc = Org.BouncyCastle; - -namespace PKISharp.WACS.Services -{ - internal class CertificateService : ICertificateService - { - private const string CsrPostFix = "-csr.pem"; - private const string PfxPostFix = "-temp.pfx"; - private const string PfxPostFixLegacy = "-cache.pfx"; - - private readonly IInputService _inputService; - private readonly ILogService _log; - private readonly ISettingsService _settings; - private readonly AcmeClient _client; - private readonly DirectoryInfo _cache; - private readonly PemService _pemService; - - public CertificateService( - ILogService log, - AcmeClient client, - PemService pemService, - IInputService inputService, - ISettingsService settingsService) - { - _log = log; - _client = client; - _pemService = pemService; - _cache = new DirectoryInfo(settingsService.Cache.Path); - _settings = settingsService; - _inputService = inputService; - CheckStaleFiles(); - } - - /// <summary> - /// List all files older than 120 days from the certificate - /// cache, because that means that the certificates have been - /// expired for 30 days. User might want to clean them up - /// </summary> - private void CheckStaleFiles() - { - var days = 120; - var files = _cache. - GetFiles(). - Where(x => x.LastWriteTime < DateTime.Now.AddDays(-days)); - var count = files.Count(); - if (count > 0) - { - _log.Warning("Found {nr} files older than {days} days in cache path '{cachePath}'", count, days, _cache.FullName); - if (_settings.Cache.DeleteStaleFiles) - { - _log.Information("Deleting stale files"); - try - { - foreach (var file in files) - { - file.Delete(); - } - _log.Information("Stale files deleted"); - } - catch (Exception ex) - { - _log.Error(ex, "Deleting stale files"); - } - } - } - } - - /// <summary> - /// Delete cached files related to a specific renewal - /// </summary> - /// <param name="renewal"></param> - private void ClearCache(Renewal renewal, string prefix = "*", string postfix = "*") - { - foreach (var f in _cache.EnumerateFiles($"{prefix}{renewal.Id}{postfix}")) - { - _log.Verbose("Deleting {file} from {folder}", f.Name, _cache.FullName); - try - { - f.Delete(); - } - catch (Exception ex) - { - _log.Warning("Error deleting {file} from {folder}: {message}", f.Name, _cache.FullName, ex.Message); - } - } - } - void ICertificateService.Delete(Renewal renewal) => ClearCache(renewal); - - /// <summary> - /// Encrypt or decrypt the cached private keys - /// </summary> - public void Encrypt() - { - foreach (var f in _cache.EnumerateFiles($"*.keys")) - { - var x = new ProtectedString(File.ReadAllText(f.FullName), _log); - _log.Information("Rewriting {x}", f.Name); - File.WriteAllText(f.FullName, x.DiskValue(_settings.Security.EncryptConfig)); - } - } - - /// <summary> - /// Find local certificate file based on naming conventions - /// </summary> - /// <param name="renewal"></param> - /// <param name="postfix"></param> - /// <param name="prefix"></param> - /// <returns></returns> - private string GetPath(Renewal renewal, string postfix, string prefix = "") => Path.Combine(_cache.FullName, $"{prefix}{renewal.Id}{postfix}"); - - /// <summary> - /// Read from the disk cache - /// </summary> - /// <param name="renewal"></param> - /// <returns></returns> - public CertificateInfo? CachedInfo(Order order) - { - var cachedInfos = CachedInfos(order.Renewal); - if (!cachedInfos.Any()) - { - return null; - } - - var keyName = GetPath(order.Renewal, $"-{CacheKey(order)}{PfxPostFix}"); - var fileCache = cachedInfos.Where(x => x.CacheFile?.FullName == keyName).FirstOrDefault(); - if (fileCache == null) - { - var legacyFile = GetPath(order.Renewal, PfxPostFixLegacy); - var candidate = cachedInfos.Where(x => x.CacheFile?.FullName == legacyFile).FirstOrDefault(); - if (candidate != null) - { - if (Match(candidate, order.Target)) - { - fileCache = candidate; - } - } - } - return fileCache; - } - - public IEnumerable<CertificateInfo> CachedInfos(Renewal renewal) - { - var ret = new List<CertificateInfo>(); - var nameAll = GetPath(renewal, "*.pfx"); - var directory = new DirectoryInfo(Path.GetDirectoryName(nameAll)); - var allPattern = Path.GetFileName(nameAll); - var allFiles = directory.EnumerateFiles(allPattern + "*"); - var fileCache = allFiles.OrderByDescending(x => x.LastWriteTime); - foreach (var file in fileCache) - { - try - { - ret.Add(FromCache(file, renewal.PfxPassword?.Value)); - } - catch - { - // File corrupt or invalid password? - _log.Warning("Unable to read {i} from certificate cache", file.Name); - } - } - return ret; - } - - /// <summary> - /// See if the information in the certificate matches - /// that of the specified target. Used to figure out whether - /// or not the cache is out of date. - /// </summary> - /// <param name="target"></param> - /// <returns></returns> - private bool Match(CertificateInfo info, Target target) - { - var identifiers = target.GetHosts(false); - var idn = new IdnMapping(); - return info.CommonName == idn.GetAscii(target.CommonName) && - info.SanNames.Count == identifiers.Count() && - info.SanNames.All(h => identifiers.Contains(idn.GetAscii(h))); - } - - /// <summary> - /// To check if it's possible to reuse a previously retrieved - /// certificate we create a hash of its key properties and included - /// that hash in the file name. If we get the same hash on a - /// subsequent run, it means it's safe to reuse (no relevant changes). - /// </summary> - /// <param name="renewal"></param> - /// <param name="target"></param> - /// <returns></returns> - public string CacheKey(Order order) - { - // Check if we can reuse a cached certificate and/or order - // based on currently active set of parameters and shape of - // the target. - var cacheKeyBuilder = new StringBuilder(); - cacheKeyBuilder.Append(order.CacheKeyPart); - cacheKeyBuilder.Append(order.Target.CommonName); - cacheKeyBuilder.Append(string.Join(',', order.Target.GetHosts(true).OrderBy(x => x).Select(x => x.ToLower()))); - _ = order.Target.CsrBytes != null ? - cacheKeyBuilder.Append(Convert.ToBase64String(order.Target.CsrBytes)) : - cacheKeyBuilder.Append("-"); - _ = order.Renewal.CsrPluginOptions != null ? - cacheKeyBuilder.Append(JsonConvert.SerializeObject(order.Renewal.CsrPluginOptions)) : - cacheKeyBuilder.Append("-"); - return cacheKeyBuilder.ToString().SHA1(); - } - - /// <summary> - /// Request certificate from the ACME server - /// </summary> - /// <param name="csrPlugin">Plugin used to generate CSR if it has not been provided in the target</param> - /// <param name="runLevel"></param> - /// <param name="renewal"></param> - /// <param name="target"></param> - /// <param name="order"></param> - /// <returns></returns> - public async Task<CertificateInfo> RequestCertificate(ICsrPlugin? csrPlugin, RunLevel runLevel, Order order) - { - if (order.Details == null) - { - throw new InvalidOperationException(); - } - - // What are we going to get? - var cacheKey = CacheKey(order); - var pfxFileInfo = new FileInfo(GetPath(order.Renewal, $"-{cacheKey}{PfxPostFix}")); - - // Determine/check the common name - var identifiers = order.Target.GetHosts(false); - var commonNameUni = order.Target.CommonName; - var commonNameAscii = string.Empty; - if (!string.IsNullOrWhiteSpace(commonNameUni)) - { - var idn = new IdnMapping(); - commonNameAscii = idn.GetAscii(commonNameUni); - if (!identifiers.Contains(commonNameAscii, StringComparer.InvariantCultureIgnoreCase)) - { - _log.Warning($"Common name {commonNameUni} provided is invalid."); - commonNameAscii = identifiers.First(); - commonNameUni = idn.GetUnicode(commonNameAscii); - } - } - - // Determine the friendly name base (for the renewal) - var friendlyNameBase = order.Renewal.FriendlyName; - if (string.IsNullOrEmpty(friendlyNameBase)) - { - friendlyNameBase = order.Target.FriendlyName; - } - if (string.IsNullOrEmpty(friendlyNameBase)) - { - friendlyNameBase = commonNameUni; - } - - // Determine the friendly name for this specific certificate - var friendlyNameIntermediate = friendlyNameBase; - if (!string.IsNullOrEmpty(order.FriendlyNamePart)) - { - friendlyNameIntermediate += $" [{order.FriendlyNamePart}]"; - } - var friendlyName = $"{friendlyNameIntermediate} @ {_inputService.FormatDate(DateTime.Now)}"; - - // Try using cached certificate first to avoid rate limiting during - // (initial?) deployment troubleshooting. Real certificate requests - // will only be done once per day maximum unless the --force parameter - // is used. - var cache = CachedInfo(order); - if (cache != null && cache.CacheFile != null) - { - if (cache.CacheFile.LastWriteTime > DateTime.Now.AddDays(_settings.Cache.ReuseDays * -1)) - { - if (runLevel.HasFlag(RunLevel.IgnoreCache)) - { - _log.Warning("Cached certificate available on disk but not used due to --{switch} switch.", - nameof(MainArguments.Force).ToLower()); - } - else - { - _log.Warning("Using cached certificate for {friendlyName}. To force a new request of the " + - "certificate within {days} days, run with the --{switch} switch.", - friendlyNameIntermediate, - _settings.Cache.ReuseDays, - nameof(MainArguments.Force).ToLower()); - return cache; - } - } - } - - if (order.Details.Payload.Status != AcmeClient.OrderValid) - { - // Clear cache and write new cert - ClearCache(order.Renewal, postfix: CsrPostFix); - - if (order.Target.CsrBytes == null) - { - if (csrPlugin == null) - { - throw new InvalidOperationException("Missing csrPlugin"); - } - var keyFile = GetPath(order.Renewal, ".keys"); - var csr = await csrPlugin.GenerateCsr(keyFile, commonNameAscii, identifiers); - var keySet = await csrPlugin.GetKeys(); - order.Target.CsrBytes = csr.GetDerEncoded(); - order.Target.PrivateKey = keySet.Private; - var csrPath = GetPath(order.Renewal, CsrPostFix); - File.WriteAllText(csrPath, _pemService.GetPem("CERTIFICATE REQUEST", order.Target.CsrBytes)); - _log.Debug("CSR stored at {path} in certificate cache folder {folder}", Path.GetFileName(csrPath), Path.GetDirectoryName(csrPath)); - - } - - _log.Verbose("Submitting CSR"); - order.Details = await _client.SubmitCsr(order.Details, order.Target.CsrBytes); - if (order.Details.Payload.Status != AcmeClient.OrderValid) - { - _log.Error("Unexpected order status {status}", order.Details.Payload.Status); - throw new Exception($"Unable to complete order"); - } - } - - _log.Information("Requesting certificate {friendlyName}", friendlyNameIntermediate); - var rawCertificate = await _client.GetCertificate(order.Details); - if (rawCertificate == null) - { - throw new Exception($"Unable to get certificate"); - } - - // Build pfx archive including any intermediates provided - var text = Encoding.UTF8.GetString(rawCertificate); - var pfx = new bc.Pkcs.Pkcs12Store(); - var startIndex = 0; - var endIndex = 0; - const string startString = "-----BEGIN CERTIFICATE-----"; - const string endString = "-----END CERTIFICATE-----"; - while (true) - { - startIndex = text.IndexOf(startString, startIndex); - if (startIndex < 0) - { - break; - } - endIndex = text.IndexOf(endString, startIndex); - if (endIndex < 0) - { - break; - } - endIndex += endString.Length; - var pem = text[startIndex..endIndex]; - var bcCertificate = _pemService.ParsePem<bc.X509.X509Certificate>(pem); - if (bcCertificate != null) - { - var bcCertificateEntry = new bc.Pkcs.X509CertificateEntry(bcCertificate); - var bcCertificateAlias = startIndex == 0 ? - friendlyName : - bcCertificate.SubjectDN.ToString(); - pfx.SetCertificateEntry(bcCertificateAlias, bcCertificateEntry); - - // Assume that the first certificate in the reponse is the main one - // so we associate the private key with that one. Other certificates - // are intermediates - if (startIndex == 0 && order.Target.PrivateKey != null) - { - var bcPrivateKeyEntry = new bc.Pkcs.AsymmetricKeyEntry(order.Target.PrivateKey); - pfx.SetKeyEntry(bcCertificateAlias, bcPrivateKeyEntry, new[] { bcCertificateEntry }); - } - } - else - { - _log.Warning("PEM data from index {0} to {1} could not be parsed as X509Certificate", startIndex, endIndex); - } - - startIndex = endIndex; - } - - var pfxStream = new MemoryStream(); - pfx.Save(pfxStream, null, new bc.Security.SecureRandom()); - pfxStream.Position = 0; - using var pfxStreamReader = new BinaryReader(pfxStream); - - var tempPfx = new X509Certificate2Collection(); - tempPfx.Import( - pfxStreamReader.ReadBytes((int)pfxStream.Length), - null, - X509KeyStorageFlags.MachineKeySet | - X509KeyStorageFlags.PersistKeySet | - X509KeyStorageFlags.Exportable); - - ClearCache(order.Renewal, postfix: $"*{PfxPostFix}"); - ClearCache(order.Renewal, postfix: $"*{PfxPostFixLegacy}"); - File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value)); - _log.Debug("Certificate written to cache file {path} in certificate cache folder {folder}. It will be " + - "reused when renewing within {x} day(s) as long as the Target and Csr parameters remain the same and " + - "the --force switch is not used.", - pfxFileInfo.Name, - pfxFileInfo.Directory.FullName, - _settings.Cache.ReuseDays); - - if (csrPlugin != null) - { - try - { - var cert = tempPfx. - OfType<X509Certificate2>(). - Where(x => x.HasPrivateKey). - FirstOrDefault(); - if (cert != null) - { - var certIndex = tempPfx.IndexOf(cert); - var newVersion = await csrPlugin.PostProcess(cert); - if (newVersion != cert) - { - newVersion.FriendlyName = friendlyName; - tempPfx[certIndex] = newVersion; - File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value)); - newVersion.Dispose(); - } - } - } - catch (Exception) - { - _log.Warning("Private key conversion error."); - } - } - - pfxFileInfo.Refresh(); - - // Update LastFriendlyName so that the user sees - // the most recently issued friendlyName in - // the WACS GUI - order.Renewal.LastFriendlyName = friendlyNameBase; - - // Recreate X509Certificate2 with correct flags for Store/Install - return FromCache(pfxFileInfo, order.Renewal.PfxPassword?.Value); - } - - private CertificateInfo FromCache(FileInfo pfxFileInfo, string? password) - { - var rawCollection = ReadAsCollection(pfxFileInfo, password); - var list = rawCollection.OfType<X509Certificate2>().ToList(); - // Get first certificate that has not been used to issue - // another one in the collection. That is the outermost leaf. - var main = list.FirstOrDefault(x => !list.Any(y => x.Subject == y.Issuer)); - list.Remove(main); - var lastChainElement = main; - var orderedCollection = new List<X509Certificate2>(); - while (list.Count > 0) - { - var signedBy = list.FirstOrDefault(x => main.Issuer == x.Subject); - if (signedBy == null) - { - // Chain cannot be resolved any further - break; - } - orderedCollection.Add(signedBy); - lastChainElement = signedBy; - list.Remove(signedBy); - } - return new CertificateInfo(main) - { - Chain = orderedCollection, - CacheFile = pfxFileInfo, - CacheFilePassword = password - }; - } - - /// <summary> - /// Read certificate for it to be exposed to the StorePlugin and InstallationPlugins - /// </summary> - /// <param name="source"></param> - /// <param name="password"></param> - /// <returns></returns> - private X509Certificate2Collection ReadAsCollection(FileInfo source, string? password) - { - // Flags used for the X509Certificate2 as - var externalFlags = - X509KeyStorageFlags.MachineKeySet | - X509KeyStorageFlags.PersistKeySet | - X509KeyStorageFlags.Exportable; - var ret = new X509Certificate2Collection(); - ret.Import(source.FullName, password, externalFlags); - return ret; - } - - /// <summary> - /// Revoke previously issued certificate - /// </summary> - /// <param name="binding"></param> - public async Task RevokeCertificate(Renewal renewal) - { - // Delete cached files - var infos = CachedInfos(renewal); - foreach (var info in infos) - { - try - { - var certificateDer = info.Certificate.Export(X509ContentType.Cert); - await _client.RevokeCertificate(certificateDer); - info.CacheFile?.Delete(); - _log.Warning($"Revoked certificate {info.Certificate.FriendlyName}"); - } - catch (Exception ex) - { - _log.Error(ex, $"Error revoking certificate {info.Certificate.FriendlyName}, you may retry"); - } - } - } - - /// <summary> - /// Common filter for different store plugins - /// </summary> - /// <param name="friendlyName"></param> - /// <returns></returns> - public static Func<X509Certificate2, bool> ThumbprintFilter(string thumbprint) => new Func<X509Certificate2, bool>(x => string.Equals(x.Thumbprint, thumbprint)); - } -} +using Newtonsoft.Json;
+using PKISharp.WACS.Clients.Acme;
+using PKISharp.WACS.Configuration;
+using PKISharp.WACS.DomainObjects;
+using PKISharp.WACS.Extensions;
+using PKISharp.WACS.Plugins.Interfaces;
+using PKISharp.WACS.Services.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading.Tasks;
+using bc = Org.BouncyCastle;
+
+namespace PKISharp.WACS.Services
+{
+ internal class CertificateService : ICertificateService
+ {
+ private const string CsrPostFix = "-csr.pem";
+ private const string PfxPostFix = "-temp.pfx";
+ private const string PfxPostFixLegacy = "-cache.pfx";
+
+ private readonly IInputService _inputService;
+ private readonly ILogService _log;
+ private readonly ISettingsService _settings;
+ private readonly AcmeClient _client;
+ private readonly DirectoryInfo _cache;
+ private readonly PemService _pemService;
+
+ public CertificateService(
+ ILogService log,
+ AcmeClient client,
+ PemService pemService,
+ IInputService inputService,
+ ISettingsService settingsService)
+ {
+ _log = log;
+ _client = client;
+ _pemService = pemService;
+ _cache = new DirectoryInfo(settingsService.Cache.Path);
+ _settings = settingsService;
+ _inputService = inputService;
+ CheckStaleFiles();
+ }
+
+ /// <summary>
+ /// List all files older than 120 days from the certificate
+ /// cache, because that means that the certificates have been
+ /// expired for 30 days. User might want to clean them up
+ /// </summary>
+ private void CheckStaleFiles()
+ {
+ var days = 120;
+ var files = _cache.
+ GetFiles().
+ Where(x => x.LastWriteTime < DateTime.Now.AddDays(-days));
+ var count = files.Count();
+ if (count > 0)
+ {
+ _log.Warning("Found {nr} files older than {days} days in cache path '{cachePath}'", count, days, _cache.FullName);
+ if (_settings.Cache.DeleteStaleFiles)
+ {
+ _log.Information("Deleting stale files");
+ try
+ {
+ foreach (var file in files)
+ {
+ file.Delete();
+ }
+ _log.Information("Stale files deleted");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Deleting stale files");
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Delete cached files related to a specific renewal
+ /// </summary>
+ /// <param name="renewal"></param>
+ private void ClearCache(Renewal renewal, string prefix = "*", string postfix = "*")
+ {
+ foreach (var f in _cache.EnumerateFiles($"{prefix}{renewal.Id}{postfix}"))
+ {
+ if (f.LastWriteTime < DateTime.Now.AddDays(_settings.Cache.ReuseDays * -1))
+ {
+ _log.Verbose("Deleting {file} from {folder}", f.Name, _cache.FullName);
+ try
+ {
+ f.Delete();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Error deleting {file} from {folder}: {message}", f.Name, _cache.FullName, ex.Message);
+ }
+ }
+ }
+ }
+ void ICertificateService.Delete(Renewal renewal) => ClearCache(renewal);
+
+ /// <summary>
+ /// Encrypt or decrypt the cached private keys
+ /// </summary>
+ public void Encrypt()
+ {
+ foreach (var f in _cache.EnumerateFiles($"*.keys"))
+ {
+ var x = new ProtectedString(File.ReadAllText(f.FullName), _log);
+ _log.Information("Rewriting {x}", f.Name);
+ File.WriteAllText(f.FullName, x.DiskValue(_settings.Security.EncryptConfig));
+ }
+ }
+
+ /// <summary>
+ /// Find local certificate file based on naming conventions
+ /// </summary>
+ /// <param name="renewal"></param>
+ /// <param name="postfix"></param>
+ /// <param name="prefix"></param>
+ /// <returns></returns>
+ private string GetPath(Renewal renewal, string postfix, string prefix = "") => Path.Combine(_cache.FullName, $"{prefix}{renewal.Id}{postfix}");
+
+ /// <summary>
+ /// Read from the disk cache
+ /// </summary>
+ /// <param name="renewal"></param>
+ /// <returns></returns>
+ public CertificateInfo? CachedInfo(Order order)
+ {
+ var cachedInfos = CachedInfos(order.Renewal);
+ if (!cachedInfos.Any())
+ {
+ return null;
+ }
+
+ var keyName = GetPath(order.Renewal, $"-{CacheKey(order)}{PfxPostFix}");
+ var fileCache = cachedInfos.Where(x => x.CacheFile?.FullName == keyName).FirstOrDefault();
+ if (fileCache == null)
+ {
+ var legacyFile = GetPath(order.Renewal, PfxPostFixLegacy);
+ var candidate = cachedInfos.Where(x => x.CacheFile?.FullName == legacyFile).FirstOrDefault();
+ if (candidate != null)
+ {
+ if (Match(candidate, order.Target))
+ {
+ fileCache = candidate;
+ }
+ }
+ }
+ return fileCache;
+ }
+
+ public IEnumerable<CertificateInfo> CachedInfos(Renewal renewal)
+ {
+ var ret = new List<CertificateInfo>();
+ var nameAll = GetPath(renewal, "*.pfx");
+ var directory = new DirectoryInfo(Path.GetDirectoryName(nameAll));
+ var allPattern = Path.GetFileName(nameAll);
+ var allFiles = directory.EnumerateFiles(allPattern + "*");
+ var fileCache = allFiles.OrderByDescending(x => x.LastWriteTime);
+ foreach (var file in fileCache)
+ {
+ try
+ {
+ ret.Add(FromCache(file, renewal.PfxPassword?.Value));
+ }
+ catch
+ {
+ // File corrupt or invalid password?
+ _log.Warning("Unable to read {i} from certificate cache", file.Name);
+ }
+ }
+ return ret;
+ }
+
+ /// <summary>
+ /// See if the information in the certificate matches
+ /// that of the specified target. Used to figure out whether
+ /// or not the cache is out of date.
+ /// </summary>
+ /// <param name="target"></param>
+ /// <returns></returns>
+ private bool Match(CertificateInfo info, Target target)
+ {
+ var identifiers = target.GetHosts(false);
+ var idn = new IdnMapping();
+ return info.CommonName == idn.GetAscii(target.CommonName) &&
+ info.SanNames.Count == identifiers.Count() &&
+ info.SanNames.All(h => identifiers.Contains(idn.GetAscii(h)));
+ }
+
+ /// <summary>
+ /// To check if it's possible to reuse a previously retrieved
+ /// certificate we create a hash of its key properties and included
+ /// that hash in the file name. If we get the same hash on a
+ /// subsequent run, it means it's safe to reuse (no relevant changes).
+ /// </summary>
+ /// <param name="renewal"></param>
+ /// <param name="target"></param>
+ /// <returns></returns>
+ public string CacheKey(Order order)
+ {
+ // Check if we can reuse a cached certificate and/or order
+ // based on currently active set of parameters and shape of
+ // the target.
+ var cacheKeyBuilder = new StringBuilder();
+ cacheKeyBuilder.Append(order.CacheKeyPart);
+ cacheKeyBuilder.Append(order.Target.CommonName);
+ cacheKeyBuilder.Append(string.Join(',', order.Target.GetHosts(true).OrderBy(x => x).Select(x => x.ToLower())));
+ _ = order.Target.CsrBytes != null ?
+ cacheKeyBuilder.Append(Convert.ToBase64String(order.Target.CsrBytes)) :
+ cacheKeyBuilder.Append("-");
+ _ = order.Renewal.CsrPluginOptions != null ?
+ cacheKeyBuilder.Append(JsonConvert.SerializeObject(order.Renewal.CsrPluginOptions)) :
+ cacheKeyBuilder.Append("-");
+ return cacheKeyBuilder.ToString().SHA1();
+ }
+
+ /// <summary>
+ /// Request certificate from the ACME server
+ /// </summary>
+ /// <param name="csrPlugin">Plugin used to generate CSR if it has not been provided in the target</param>
+ /// <param name="runLevel"></param>
+ /// <param name="renewal"></param>
+ /// <param name="target"></param>
+ /// <param name="order"></param>
+ /// <returns></returns>
+ public async Task<CertificateInfo> RequestCertificate(ICsrPlugin? csrPlugin, RunLevel runLevel, Order order)
+ {
+ if (order.Details == null)
+ {
+ throw new InvalidOperationException();
+ }
+
+ // What are we going to get?
+ var cacheKey = CacheKey(order);
+ var pfxFileInfo = new FileInfo(GetPath(order.Renewal, $"-{cacheKey}{PfxPostFix}"));
+
+ // Determine/check the common name
+ var identifiers = order.Target.GetHosts(false);
+ var commonNameUni = order.Target.CommonName;
+ var commonNameAscii = string.Empty;
+ if (!string.IsNullOrWhiteSpace(commonNameUni))
+ {
+ var idn = new IdnMapping();
+ commonNameAscii = idn.GetAscii(commonNameUni);
+ if (!identifiers.Contains(commonNameAscii, StringComparer.InvariantCultureIgnoreCase))
+ {
+ _log.Warning($"Common name {commonNameUni} provided is invalid.");
+ commonNameAscii = identifiers.First();
+ commonNameUni = idn.GetUnicode(commonNameAscii);
+ }
+ }
+
+ // Determine the friendly name base (for the renewal)
+ var friendlyNameBase = order.Renewal.FriendlyName;
+ if (string.IsNullOrEmpty(friendlyNameBase))
+ {
+ friendlyNameBase = order.Target.FriendlyName;
+ }
+ if (string.IsNullOrEmpty(friendlyNameBase))
+ {
+ friendlyNameBase = commonNameUni;
+ }
+
+ // Determine the friendly name for this specific certificate
+ var friendlyNameIntermediate = friendlyNameBase;
+ if (!string.IsNullOrEmpty(order.FriendlyNamePart))
+ {
+ friendlyNameIntermediate += $" [{order.FriendlyNamePart}]";
+ }
+ var friendlyName = $"{friendlyNameIntermediate} @ {_inputService.FormatDate(DateTime.Now)}";
+
+ // Try using cached certificate first to avoid rate limiting during
+ // (initial?) deployment troubleshooting. Real certificate requests
+ // will only be done once per day maximum unless the --force parameter
+ // is used.
+ var cache = CachedInfo(order);
+ if (cache != null && cache.CacheFile != null)
+ {
+ if (cache.CacheFile.LastWriteTime > DateTime.Now.AddDays(_settings.Cache.ReuseDays * -1))
+ {
+ if (runLevel.HasFlag(RunLevel.IgnoreCache))
+ {
+ _log.Warning("Cached certificate available on disk but not used due to --{switch} switch.",
+ nameof(MainArguments.Force).ToLower());
+ }
+ else
+ {
+ _log.Warning("Using cached certificate for {friendlyName}. To force a new request of the " +
+ "certificate within {days} days, run with the --{switch} switch.",
+ friendlyNameIntermediate,
+ _settings.Cache.ReuseDays,
+ nameof(MainArguments.Force).ToLower());
+ return cache;
+ }
+ }
+ }
+
+ if (order.Details.Payload.Status != AcmeClient.OrderValid)
+ {
+ // Clear cache and write new cert
+ ClearCache(order.Renewal, postfix: CsrPostFix);
+
+ if (order.Target.CsrBytes == null)
+ {
+ if (csrPlugin == null)
+ {
+ throw new InvalidOperationException("Missing csrPlugin");
+ }
+ // Backwards compatible with existing keys, which are not split per order yet.
+ var keyFile = new FileInfo(GetPath(order.Renewal, $".keys"));
+ if (!keyFile.Exists)
+ {
+ keyFile = new FileInfo(GetPath(order.Renewal, $"-{cacheKey}.keys"));
+ }
+ var csr = await csrPlugin.GenerateCsr(keyFile.FullName, commonNameAscii, identifiers);
+ var keySet = await csrPlugin.GetKeys();
+ order.Target.CsrBytes = csr.GetDerEncoded();
+ order.Target.PrivateKey = keySet.Private;
+ var csrPath = GetPath(order.Renewal, $"-{cacheKey}{CsrPostFix}");
+ File.WriteAllText(csrPath, _pemService.GetPem("CERTIFICATE REQUEST", order.Target.CsrBytes));
+ _log.Debug("CSR stored at {path} in certificate cache folder {folder}", Path.GetFileName(csrPath), Path.GetDirectoryName(csrPath));
+
+ }
+
+ _log.Verbose("Submitting CSR");
+ order.Details = await _client.SubmitCsr(order.Details, order.Target.CsrBytes);
+ if (order.Details.Payload.Status != AcmeClient.OrderValid)
+ {
+ _log.Error("Unexpected order status {status}", order.Details.Payload.Status);
+ throw new Exception($"Unable to complete order");
+ }
+ }
+
+ _log.Information("Requesting certificate {friendlyName}", friendlyNameIntermediate);
+ var rawCertificate = await _client.GetCertificate(order.Details);
+ if (rawCertificate == null)
+ {
+ throw new Exception($"Unable to get certificate");
+ }
+
+ // Build pfx archive including any intermediates provided
+ var text = Encoding.UTF8.GetString(rawCertificate);
+ var pfx = new bc.Pkcs.Pkcs12Store();
+ var startIndex = 0;
+ var endIndex = 0;
+ const string startString = "-----BEGIN CERTIFICATE-----";
+ const string endString = "-----END CERTIFICATE-----";
+ while (true)
+ {
+ startIndex = text.IndexOf(startString, startIndex);
+ if (startIndex < 0)
+ {
+ break;
+ }
+ endIndex = text.IndexOf(endString, startIndex);
+ if (endIndex < 0)
+ {
+ break;
+ }
+ endIndex += endString.Length;
+ var pem = text[startIndex..endIndex];
+ var bcCertificate = _pemService.ParsePem<bc.X509.X509Certificate>(pem);
+ if (bcCertificate != null)
+ {
+ var bcCertificateEntry = new bc.Pkcs.X509CertificateEntry(bcCertificate);
+ var bcCertificateAlias = startIndex == 0 ?
+ friendlyName :
+ bcCertificate.SubjectDN.ToString();
+ pfx.SetCertificateEntry(bcCertificateAlias, bcCertificateEntry);
+
+ // Assume that the first certificate in the reponse is the main one
+ // so we associate the private key with that one. Other certificates
+ // are intermediates
+ if (startIndex == 0 && order.Target.PrivateKey != null)
+ {
+ var bcPrivateKeyEntry = new bc.Pkcs.AsymmetricKeyEntry(order.Target.PrivateKey);
+ pfx.SetKeyEntry(bcCertificateAlias, bcPrivateKeyEntry, new[] { bcCertificateEntry });
+ }
+ }
+ else
+ {
+ _log.Warning("PEM data from index {0} to {1} could not be parsed as X509Certificate", startIndex, endIndex);
+ }
+
+ startIndex = endIndex;
+ }
+
+ var pfxStream = new MemoryStream();
+ pfx.Save(pfxStream, null, new bc.Security.SecureRandom());
+ pfxStream.Position = 0;
+ using var pfxStreamReader = new BinaryReader(pfxStream);
+
+ var tempPfx = new X509Certificate2Collection();
+ tempPfx.Import(
+ pfxStreamReader.ReadBytes((int)pfxStream.Length),
+ null,
+ X509KeyStorageFlags.MachineKeySet |
+ X509KeyStorageFlags.PersistKeySet |
+ X509KeyStorageFlags.Exportable);
+
+ ClearCache(order.Renewal, postfix: $"*{PfxPostFix}");
+ ClearCache(order.Renewal, postfix: $"*{PfxPostFixLegacy}");
+ File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value));
+ _log.Debug("Certificate written to cache file {path} in certificate cache folder {folder}. It will be " +
+ "reused when renewing within {x} day(s) as long as the Target and Csr parameters remain the same and " +
+ "the --force switch is not used.",
+ pfxFileInfo.Name,
+ pfxFileInfo.Directory.FullName,
+ _settings.Cache.ReuseDays);
+
+ if (csrPlugin != null)
+ {
+ try
+ {
+ var cert = tempPfx.
+ OfType<X509Certificate2>().
+ Where(x => x.HasPrivateKey).
+ FirstOrDefault();
+ if (cert != null)
+ {
+ var certIndex = tempPfx.IndexOf(cert);
+ var newVersion = await csrPlugin.PostProcess(cert);
+ if (newVersion != cert)
+ {
+ newVersion.FriendlyName = friendlyName;
+ tempPfx[certIndex] = newVersion;
+ File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value));
+ newVersion.Dispose();
+ }
+ }
+ }
+ catch (Exception)
+ {
+ _log.Warning("Private key conversion error.");
+ }
+ }
+
+ pfxFileInfo.Refresh();
+
+ // Update LastFriendlyName so that the user sees
+ // the most recently issued friendlyName in
+ // the WACS GUI
+ order.Renewal.LastFriendlyName = friendlyNameBase;
+
+ // Recreate X509Certificate2 with correct flags for Store/Install
+ return FromCache(pfxFileInfo, order.Renewal.PfxPassword?.Value);
+ }
+
+ private CertificateInfo FromCache(FileInfo pfxFileInfo, string? password)
+ {
+ var rawCollection = ReadAsCollection(pfxFileInfo, password);
+ var list = rawCollection.OfType<X509Certificate2>().ToList();
+ // Get first certificate that has not been used to issue
+ // another one in the collection. That is the outermost leaf.
+ var main = list.FirstOrDefault(x => !list.Any(y => x.Subject == y.Issuer));
+ list.Remove(main);
+ var lastChainElement = main;
+ var orderedCollection = new List<X509Certificate2>();
+ while (list.Count > 0)
+ {
+ var signedBy = list.FirstOrDefault(x => main.Issuer == x.Subject);
+ if (signedBy == null)
+ {
+ // Chain cannot be resolved any further
+ break;
+ }
+ orderedCollection.Add(signedBy);
+ lastChainElement = signedBy;
+ list.Remove(signedBy);
+ }
+ return new CertificateInfo(main)
+ {
+ Chain = orderedCollection,
+ CacheFile = pfxFileInfo,
+ CacheFilePassword = password
+ };
+ }
+
+ /// <summary>
+ /// Read certificate for it to be exposed to the StorePlugin and InstallationPlugins
+ /// </summary>
+ /// <param name="source"></param>
+ /// <param name="password"></param>
+ /// <returns></returns>
+ private X509Certificate2Collection ReadAsCollection(FileInfo source, string? password)
+ {
+ // Flags used for the X509Certificate2 as
+ var externalFlags =
+ X509KeyStorageFlags.MachineKeySet |
+ X509KeyStorageFlags.PersistKeySet |
+ X509KeyStorageFlags.Exportable;
+ var ret = new X509Certificate2Collection();
+ ret.Import(source.FullName, password, externalFlags);
+ return ret;
+ }
+
+ /// <summary>
+ /// Revoke previously issued certificate
+ /// </summary>
+ /// <param name="binding"></param>
+ public async Task RevokeCertificate(Renewal renewal)
+ {
+ // Delete cached files
+ var infos = CachedInfos(renewal);
+ foreach (var info in infos)
+ {
+ try
+ {
+ var certificateDer = info.Certificate.Export(X509ContentType.Cert);
+ await _client.RevokeCertificate(certificateDer);
+ info.CacheFile?.Delete();
+ _log.Warning($"Revoked certificate {info.Certificate.FriendlyName}");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, $"Error revoking certificate {info.Certificate.FriendlyName}, you may retry");
+ }
+ }
+ }
+
+ /// <summary>
+ /// Common filter for different store plugins
+ /// </summary>
+ /// <param name="friendlyName"></param>
+ /// <returns></returns>
+ public static Func<X509Certificate2, bool> ThumbprintFilter(string thumbprint) => new Func<X509Certificate2, bool>(x => string.Equals(x.Thumbprint, thumbprint));
+ }
+}
diff --git a/src/main.lib/Services/DomainParseService.cs b/src/main.lib/Services/DomainParseService.cs index a74dfca..17d86e1 100644 --- a/src/main.lib/Services/DomainParseService.cs +++ b/src/main.lib/Services/DomainParseService.cs @@ -54,8 +54,8 @@ namespace PKISharp.WACS.Services } public string GetTLD(string fulldomain) => Parser.Get(fulldomain).TLD; - public string GetDomain(string fulldomain) => Parser.Get(fulldomain).Domain; - + public string GetRegisterableDomain(string fulldomain) => Parser.Get(fulldomain).RegistrableDomain; + /// <summary> /// Regular 7 day file cache in the configuration folder /// </summary> diff --git a/src/main.lib/Services/InputService.cs b/src/main.lib/Services/InputService.cs index e4f51a1..829cf5e 100644 --- a/src/main.lib/Services/InputService.cs +++ b/src/main.lib/Services/InputService.cs @@ -30,7 +30,7 @@ namespace PKISharp.WACS.Services } } - protected void CreateSpace(bool force = false) + public void CreateSpace() { if (_log.Dirty || _dirty) { @@ -38,9 +38,24 @@ namespace PKISharp.WACS.Services _dirty = false; Console.WriteLine(); } - else if (force) + } + + public Task<bool> Continue(string message = "Press <Space> to continue...") + { + Validate(message); + CreateSpace(); + Console.Write($" {message} "); + while (true) { - Console.WriteLine(); + var response = Console.ReadKey(true); + switch (response.Key) + { + case ConsoleKey.Spacebar: + Console.SetCursorPosition(0, Console.CursorTop); + Console.Write(new string(' ', Console.WindowWidth)); + Console.SetCursorPosition(0, Console.CursorTop); + return Task.FromResult(true); + } } } @@ -82,12 +97,8 @@ namespace PKISharp.WACS.Services return ""; } - public void Show(string? label, string? value, bool newLine = false, int level = 0) + public void Show(string? label, string? value, int level = 0) { - if (newLine) - { - CreateSpace(); - } var hasLabel = !string.IsNullOrEmpty(label); if (hasLabel) { @@ -417,7 +428,7 @@ namespace PKISharp.WACS.Services // Paging if (currentIndex > 0) { - if (await Wait()) + if (await Continue()) { currentPage += 1; } diff --git a/src/main.lib/Services/Interfaces/IArgumentsProvider.cs b/src/main.lib/Services/Interfaces/IArgumentsProvider.cs index 58c6a16..a6643ee 100644 --- a/src/main.lib/Services/Interfaces/IArgumentsProvider.cs +++ b/src/main.lib/Services/Interfaces/IArgumentsProvider.cs @@ -28,6 +28,11 @@ namespace PKISharp.WACS.Services bool Default { get; } /// <summary> + /// Reference to the logging service + /// </summary> + ILogService? Log { get; set; } + + /// <summary> /// Which options are available /// </summary> IEnumerable<ICommandLineOption> Configuration { get; } @@ -40,7 +45,7 @@ namespace PKISharp.WACS.Services /// <summary> /// Get the parsed result /// </summary> - object GetResult(string[] args); + object? GetResult(string[] args); /// <summary> /// Validate against the main arguments @@ -48,7 +53,7 @@ namespace PKISharp.WACS.Services /// <param name="current"></param> /// <param name="main"></param> /// <returns></returns> - bool Validate(ILogService log, object current, MainArguments main); + bool Validate(object current, MainArguments main); /// <summary> /// Are the arguments provided? @@ -58,12 +63,12 @@ namespace PKISharp.WACS.Services bool Active(object current); } - public interface IArgumentsProvider<T> : IArgumentsProvider where T : new() + public interface IArgumentsProvider<T> : IArgumentsProvider where T : class, new() { /// <summary> /// Get the parsed result /// </summary> - new T GetResult(string[] args); + new T? GetResult(string[] args); /// <summary> /// Validate against the main arguments @@ -71,6 +76,6 @@ namespace PKISharp.WACS.Services /// <param name="current"></param> /// <param name="main"></param> /// <returns></returns> - bool Validate(ILogService log, T current, MainArguments main); + bool Validate(T current, MainArguments main); } } diff --git a/src/main.lib/Services/Interfaces/IArgumentsService.cs b/src/main.lib/Services/Interfaces/IArgumentsService.cs index 4c00c51..08d2767 100644 --- a/src/main.lib/Services/Interfaces/IArgumentsService.cs +++ b/src/main.lib/Services/Interfaces/IArgumentsService.cs @@ -6,7 +6,7 @@ namespace PKISharp.WACS.Services public interface IArgumentsService { MainArguments MainArguments { get; } - T GetArguments<T>() where T : class, new(); + T? GetArguments<T>() where T : class, new(); bool Active { get; } bool Valid { get; } bool HasFilter(); 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/Interfaces/IInputService.cs b/src/main.lib/Services/Interfaces/IInputService.cs index b59f82c..f911beb 100644 --- a/src/main.lib/Services/Interfaces/IInputService.cs +++ b/src/main.lib/Services/Interfaces/IInputService.cs @@ -13,7 +13,8 @@ namespace PKISharp.WACS.Services Task<string?> ReadPassword(string what); Task<string> RequestString(string what); Task<string> RequestString(string[] what); - void Show(string? label, string? value = null, bool first = false, int level = 0); + void CreateSpace(); + void Show(string? label, string? value = null, int level = 0); Task<bool> Wait(string message = "Press <Enter> to continue"); Task WritePagedList(IEnumerable<Choice> listItems); string FormatDate(DateTime date); diff --git a/src/main.lib/Services/Interfaces/ILogService.cs b/src/main.lib/Services/Interfaces/ILogService.cs index 9bc182b..e5f060b 100644 --- a/src/main.lib/Services/Interfaces/ILogService.cs +++ b/src/main.lib/Services/Interfaces/ILogService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace PKISharp.WACS.Services { @@ -10,13 +11,16 @@ namespace PKISharp.WACS.Services Screen = 1, Event = 2, Disk = 4, - All = Screen | Event | Disk + Notification = 8, + All = int.MaxValue } public interface ILogService { bool Dirty { get; set; } + IEnumerable<MemoryEntry> Lines { get; } + void Reset(); void Debug(string message, params object?[] items); void Error(Exception ex, string message, params object?[] items); void Error(string message, params object?[] items); diff --git a/src/main.lib/Services/Legacy/Importer.cs b/src/main.lib/Services/Legacy/Importer.cs index 4359d3c..efa06b3 100644 --- a/src/main.lib/Services/Legacy/Importer.cs +++ b/src/main.lib/Services/Legacy/Importer.cs @@ -77,13 +77,13 @@ namespace PKISharp.WACS.Services.Legacy listCommand = "Manage renewals"; renewCommand = "Run"; } + _input.CreateSpace(); _input.Show(null, value: $"The renewals have now been imported into this new version " + "of the program. Nothing else will happen until new scheduled task is " + "first run *or* you trigger them manually. It is highly recommended " + $"to review the imported items with '{listCommand}' and to monitor the " + - $"results of the first execution with '{renewCommand}'.", - @first: true); + $"results of the first execution with '{renewCommand}'."); } diff --git a/src/main.lib/Services/LogService.cs b/src/main.lib/Services/Log/LogService.cs index e88997d..eb1e429 100644 --- a/src/main.lib/Services/LogService.cs +++ b/src/main.lib/Services/Log/LogService.cs @@ -4,8 +4,10 @@ using Serilog.Core; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; namespace PKISharp.WACS.Services @@ -16,10 +18,16 @@ namespace PKISharp.WACS.Services private readonly Logger? _debugScreenLogger; private readonly Logger? _eventLogger; private Logger? _diskLogger; + private readonly Logger? _notificationLogger; private readonly LoggingLevelSwitch _levelSwitch; + private readonly List<MemoryEntry> _lines = new List<MemoryEntry>(); + public bool Dirty { get; set; } private string _configurationPath { get; } + public IEnumerable<MemoryEntry> Lines => _lines.AsEnumerable(); + public void Reset() => _lines.Clear(); + public LogService() { // Custom configuration support @@ -82,6 +90,13 @@ namespace PKISharp.WACS.Services { Warning("Error creating event logger: {ex}", ex.Message); } + + _notificationLogger = new LoggerConfiguration() + .MinimumLevel.ControlledBy(_levelSwitch) + .Enrich.FromLogContext() + .WriteTo.Memory(_lines) + .CreateLogger(); + Log.Debug("The global logger has been configured"); } @@ -181,6 +196,10 @@ namespace PKISharp.WACS.Services { _debugScreenLogger.Write(level, ex, message, items); } + if (_notificationLogger != null) + { + _notificationLogger.Write(level, ex, message, items); + } } if (_eventLogger != null && type.HasFlag(LogType.Event)) { @@ -191,5 +210,6 @@ namespace PKISharp.WACS.Services _diskLogger.Write(level, ex, message, items); } } + } } diff --git a/src/main.lib/Services/Log/MemorySink.cs b/src/main.lib/Services/Log/MemorySink.cs new file mode 100644 index 0000000..70d2211 --- /dev/null +++ b/src/main.lib/Services/Log/MemorySink.cs @@ -0,0 +1,46 @@ +using PKISharp.WACS.Services; +using Serilog.Configuration; +using Serilog.Core; +using Serilog.Events; +using System; +using System.Collections.Generic; + +namespace PKISharp.WACS.Services +{ + public class MemoryEntry + { + public MemoryEntry(LogEventLevel level, string message) + { + Level = level; + Message = message; + } + + public LogEventLevel Level { get; set; } + public string Message { get; set; } + } + + class MemorySink : ILogEventSink + { + private readonly IFormatProvider? _formatProvider; + private readonly List<MemoryEntry> _list; + + public MemorySink(List<MemoryEntry> list, IFormatProvider? formatProvider = null) + { + _formatProvider = formatProvider; + _list = list; + } + + public void Emit(LogEvent logEvent) => _list.Add(new MemoryEntry(logEvent.Level, logEvent.RenderMessage(_formatProvider))); + } +} + +namespace Serilog +{ + /// <summary> + /// Adds the WriteTo.Memory() extension method to <see cref="LoggerConfiguration"/>. + /// </summary> + public static class LoggerConfigurationStackifyExtensions + { + public static LoggerConfiguration Memory(this LoggerSinkConfiguration loggerConfiguration, List<MemoryEntry> target, IFormatProvider? formatProvider = null) => loggerConfiguration.Sink(new MemorySink(target, formatProvider)); + } +} diff --git a/src/main.lib/Services/NotificationService.cs b/src/main.lib/Services/NotificationService.cs index 62e344e..7795fac 100644 --- a/src/main.lib/Services/NotificationService.cs +++ b/src/main.lib/Services/NotificationService.cs @@ -1,10 +1,12 @@ using MimeKit; using PKISharp.WACS.Clients; using PKISharp.WACS.DomainObjects; +using Serilog.Events; using System; using System.Collections.Generic; using System.Linq; -using System.Net.Mail; +using System.Threading.Tasks; +using System.Web; namespace PKISharp.WACS.Services { @@ -28,19 +30,47 @@ namespace PKISharp.WACS.Services } /// <summary> + /// Handle created notification + /// </summary> + /// <param name="runLevel"></param> + /// <param name="renewal"></param> + internal async Task NotifyCreated(Renewal renewal, IEnumerable<MemoryEntry> log) + { + // Do not send emails when running interactively + _log.Information( + LogType.All, + "Certificate {friendlyName} created", + renewal.LastFriendlyName); + if (_settings.Notification.EmailOnSuccess) + { + await _email.Send( + $"Certificate {renewal.LastFriendlyName} created", + @$"<p>Certificate <b>{HttpUtility.HtmlEncode(renewal.LastFriendlyName)}</b> succesfully created.</p> + {NotificationInformation(renewal)} + {RenderLog(log)}", + MessagePriority.Normal); + } + } + + /// <summary> /// Handle success notification /// </summary> /// <param name="runLevel"></param> /// <param name="renewal"></param> - internal void NotifySuccess(RunLevel runLevel, Renewal renewal) + internal async Task NotifySuccess(Renewal renewal, IEnumerable<MemoryEntry> log) { // Do not send emails when running interactively - _log.Information(LogType.All, "Renewal for {friendlyName} succeeded", renewal.LastFriendlyName); - if (runLevel.HasFlag(RunLevel.Unattended) && _settings.Notification.EmailOnSuccess) + _log.Information( + LogType.All, + "Renewal for {friendlyName} succeeded", + renewal.LastFriendlyName); + if (_settings.Notification.EmailOnSuccess) { - _email.Send( - "Certificate renewal completed", - $"<p>Certificate <b>{renewal.LastFriendlyName}</b> succesfully renewed.</p> {NotificationInformation(renewal)}", + await _email.Send( + $"Certificate renewal {renewal.LastFriendlyName} completed", + @$"<p>Certificate <b>{HttpUtility.HtmlEncode(renewal.LastFriendlyName)}</b> succesfully renewed.</p> + {NotificationInformation(renewal)} + {RenderLog(log)}", MessagePriority.NonUrgent); } } @@ -50,42 +80,79 @@ namespace PKISharp.WACS.Services /// </summary> /// <param name="runLevel"></param> /// <param name="renewal"></param> - internal void NotifyFailure(RunLevel runLevel, Renewal renewal, List<string> errorMessage) + internal async Task NotifyFailure( + RunLevel runLevel, + Renewal renewal, + List<string> errors, + IEnumerable<MemoryEntry> log) { // Do not send emails when running interactively _log.Error("Renewal for {friendlyName} failed, will retry on next run", renewal.LastFriendlyName); - if (errorMessage.Count == 0) + if (errors.Count == 0) { - errorMessage.Add("No specific error reason provided."); + errors.Add("No specific error reason provided."); } if (runLevel.HasFlag(RunLevel.Unattended)) { - _email.Send("Error processing certificate renewal", - @$"<p>Renewal for <b>{renewal.LastFriendlyName}</b> failed with error(s) - <ul><li>{string.Join("</li><li>", errorMessage)}</li></ul> will retry - on next run.</p> {NotificationInformation(renewal)}", + 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> + {NotificationInformation(renewal)} + {RenderLog(log)}", MessagePriority.Urgent); } } + private string RenderLog(IEnumerable<MemoryEntry> log) => @$"<p>Log output:<ul><li>{string.Join("</li><li>", log.Select(x => RenderLogEntry(x)))}</ul></p>"; + + private string RenderLogEntry(MemoryEntry log) + { + var color = $"00000"; + switch (log.Level) + { + case LogEventLevel.Error: + case LogEventLevel.Fatal: + color = "#8B0000"; + break; + + case LogEventLevel.Warning: + color = "#CCCC00"; + break; + + case LogEventLevel.Information: + color = "#000000"; + break; + + case LogEventLevel.Debug: + case LogEventLevel.Verbose: + color = "#a9a9a9"; + break; + } + return $"<span style=\"color:{color}\">{log.Level} - {HttpUtility.HtmlEncode(log.Message)}</span>"; + } + private string NotificationInformation(Renewal renewal) { try { - var extraMessage = ""; - extraMessage += $"<p>Hosts: {NotificationHosts(renewal)}</p>"; - extraMessage += "<p><table><tr><td>Plugins</td><td></td></tr>"; - extraMessage += $"<tr><td>Target: </td><td> {renewal.TargetPluginOptions.Name}</td></tr>"; - extraMessage += $"<tr><td>Validation: </td><td> {renewal.ValidationPluginOptions.Name}</td></tr>"; + var extraMessage = @$"<p> + <table> + <tr><td><b>Hosts</b><td><td></td></tr> + <tr><td colspan=""2"">{NotificationHosts(renewal)}</td></tr> + <tr><td colspan=""2""> </td></tr> + <tr><td><b>Plugins</b></td><td></td></tr> + <tr><td>Target: </td><td> {renewal.TargetPluginOptions.Name}</td></tr>"; + extraMessage += @$"<tr><td>Validation: </td><td> {renewal.ValidationPluginOptions.Name}</td></tr>"; if (renewal.OrderPluginOptions != null) { - extraMessage += $"<tr><td>Order: </td><td> {renewal.OrderPluginOptions.Name}</td></tr>"; + extraMessage += @$"<tr><td>Order: </td><td> {renewal.OrderPluginOptions.Name}</td></tr>"; } if (renewal.CsrPluginOptions != null) { - extraMessage += $"<tr><td>CSR: </td><td> {renewal.CsrPluginOptions.Name}</td></tr>"; + extraMessage += @$"<tr><td>Csr: </td><td> {renewal.CsrPluginOptions.Name}</td></tr>"; } - extraMessage += $"<tr><td>Store: </td><td> {string.Join(", ", renewal.StorePluginOptions.Select(x => x.Name))}</td></tr>"; + extraMessage += @$"<tr><td>Store: </td><td> {string.Join(", ", renewal.StorePluginOptions.Select(x => x.Name))}</td></tr>"; extraMessage += $"<tr><td>Installation: </td><td> {string.Join(", ", renewal.InstallationPluginOptions.Select(x => x.Name))}</td></tr>"; extraMessage += "</table></p>"; return extraMessage; diff --git a/src/main.lib/Services/PluginService.cs b/src/main.lib/Services/PluginService.cs index 404e65d..af06227 100644 --- a/src/main.lib/Services/PluginService.cs +++ b/src/main.lib/Services/PluginService.cs @@ -17,7 +17,7 @@ namespace PKISharp.WACS.Services private readonly List<Type> _argumentProviders; private readonly List<Type> _optionFactories; private readonly List<Type> _plugins; - + internal readonly ILogService _log; public IEnumerable<IArgumentsProvider> ArgumentsProviders() @@ -25,7 +25,9 @@ namespace PKISharp.WACS.Services return _argumentProviders.Select(x => { var c = x.GetConstructor(new Type[] { }); - return (IArgumentsProvider)c.Invoke(new object[] { }); + var ret = (IArgumentsProvider)c.Invoke(new object[] { }); + ret.Log = _log; + return ret; }).ToList(); } @@ -105,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/ProxyService.cs b/src/main.lib/Services/ProxyService.cs index f2d49a9..9287ed1 100644 --- a/src/main.lib/Services/ProxyService.cs +++ b/src/main.lib/Services/ProxyService.cs @@ -2,6 +2,8 @@ using System.Net; using System.Net.Http; using System.Security.Authentication; +using System.Threading; +using System.Threading.Tasks; namespace PKISharp.WACS.Services { @@ -29,7 +31,7 @@ namespace PKISharp.WACS.Services /// <returns></returns> public HttpClient GetHttpClient(bool checkSsl = true) { - var httpClientHandler = new HttpClientHandler() + var httpClientHandler = new LoggingHttpClientHandler(_log) { Proxy = GetWebProxy(), SslProtocols = SslProtocols @@ -45,6 +47,21 @@ namespace PKISharp.WACS.Services return new HttpClient(httpClientHandler); } + private class LoggingHttpClientHandler : HttpClientHandler + { + private readonly ILogService _log; + + public LoggingHttpClientHandler(ILogService log) => _log = log; + + protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + _log.Debug("Send {method} request to {uri}", request.Method, request.RequestUri); + var response = await base.SendAsync(request, cancellationToken); + _log.Verbose("Request completed with status {s}", response.StatusCode); + return response; + } + } + + /// <summary> /// Get proxy server to use for web requests /// </summary> diff --git a/src/main.lib/Services/RenewalStoreDisk.cs b/src/main.lib/Services/RenewalStoreDisk.cs index 5527a41..ca35b1d 100644 --- a/src/main.lib/Services/RenewalStoreDisk.cs +++ b/src/main.lib/Services/RenewalStoreDisk.cs @@ -34,10 +34,25 @@ namespace PKISharp.WACS.Services var list = new List<Renewal>(); var di = new DirectoryInfo(_settings.Client.ConfigurationPath); var postFix = ".renewal.json"; - foreach (var rj in di.EnumerateFiles($"*{postFix}", SearchOption.AllDirectories)) + var renewalFiles = di.EnumerateFiles($"*{postFix}", SearchOption.AllDirectories); + foreach (var rj in renewalFiles) { try { + // Just checking if we have write permission + using var writeStream = rj.OpenWrite(); + } + catch (Exception ex) + { + _log.Warning("No write access to all renewals: {reason}", ex.Message); + break; + } + } + foreach (var rj in renewalFiles) + { + try + { + var storeConverter = new PluginOptionsConverter<StorePluginOptions>(_plugin.PluginOptionTypes<StorePluginOptions>(), _log); var result = JsonConvert.DeserializeObject<Renewal>( File.ReadAllText(rj.FullName), @@ -126,12 +141,19 @@ namespace PKISharp.WACS.Services var file = RenewalFile(renewal, _settings.Client.ConfigurationPath); if (file != null) { - File.WriteAllText(file.FullName, JsonConvert.SerializeObject(renewal, new JsonSerializerSettings + try { - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented, - Converters = { new ProtectedStringConverter(_log, _settings) } - })); + File.WriteAllText(file.FullName, JsonConvert.SerializeObject(renewal, new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented, + Converters = { new ProtectedStringConverter(_log, _settings) } + })); + } + catch (Exception ex) + { + _log.Error(ex, "Unable to write {renewal} to disk", renewal.LastFriendlyName); + } } renewal.New = false; renewal.Updated = false; diff --git a/src/main.lib/Services/SettingsService.cs b/src/main.lib/Services/SettingsService.cs index 0fb54c5..e930c96 100644 --- a/src/main.lib/Services/SettingsService.cs +++ b/src/main.lib/Services/SettingsService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using PKISharp.WACS.Extensions; +using PKISharp.WACS.Plugins.StorePlugins; using System; using System.Collections.Generic; using System.Diagnostics; @@ -484,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> @@ -551,12 +557,61 @@ namespace PKISharp.WACS.Services /// </summary> public string? DefaultStore { get; set; } + [Obsolete] + public string? DefaultCertificateStore { get; set; } + [Obsolete] + public string? DefaultCentralSslStore { get; set; } + [Obsolete] + public string? DefaultCentralSslPfxPassword { get; set; } + [Obsolete] + public string? DefaultPemFilesPath { get; set; } + + /// <summary> + /// Settings for the CentralSsl plugin + /// </summary> + public CertificateStoreSettings? CertificateStore { get; set; } + + /// <summary> + /// Settings for the CentralSsl plugin + /// </summary> + public CentralSslSettings? CentralSsl { get; set; } + + /// <summary> + /// Settings for the PemFiles plugin + /// </summary> + public PemFilesSettings? PemFiles { get; set; } + + /// <summary> + /// Settings for the PfxFile plugin + /// </summary> + public PfxFileSettings? PfxFile { get; set; } + + } + + public class CertificateStoreSettings + { /// <summary> /// The certificate store to save the certificates in. If left empty, /// certificates will be installed either in the WebHosting store, /// or if that is not available, the My store (better known as Personal). /// </summary> - public string? DefaultCertificateStore { get; set; } + public string? DefaultStore { get; set; } + } + + public class PemFilesSettings + { + /// <summary> + /// When using --store pemfiles this path is used by default, saving + /// you the effort from providing it manually. Filling this out makes + /// the --pemfilespath parameter unnecessary in most cases. Renewals + /// created with the default path will automatically change to any + /// future default value, meaning this is also a good practice for + /// maintainability. + /// </summary> + public string? DefaultPath{ get; set; } + } + public class CentralSslSettings + { /// <summary> /// When using --store centralssl this path is used by default, saving you /// the effort from providing it manually. Filling this out makes the @@ -565,7 +620,7 @@ namespace PKISharp.WACS.Services /// future default value, meaning this is also a good practice for /// maintainability. /// </summary> - public string? DefaultCentralSslStore { get; set; } + public string? DefaultPath { get; set; } /// <summary> /// When using --store centralssl this password is used by default for /// the pfx files, saving you the effort from providing it manually. @@ -574,16 +629,29 @@ namespace PKISharp.WACS.Services /// automatically change to any future default value, meaning this /// is also a good practice for maintainability. /// </summary> - public string? DefaultCentralSslPfxPassword { get; set; } + public string? DefaultPassword { get; set; } + } + + public class PfxFileSettings + { /// <summary> - /// When using --store pemfiles this path is used by default, saving + /// When using --store pfxfile this path is used by default, saving /// you the effort from providing it manually. Filling this out makes - /// the --pemfilespath parameter unnecessary in most cases. Renewals + /// the --pfxfilepath parameter unnecessary in most cases. Renewals /// created with the default path will automatically change to any /// future default value, meaning this is also a good practice for /// maintainability. /// </summary> - public string? DefaultPemFilesPath { get; set; } + public string? DefaultPath { get; set; } + /// <summary> + /// When using --store pfxfile this password is used by default for + /// the pfx files, saving you the effort from providing it manually. + /// Filling this out makes the --pfxpassword parameter unnecessary in + /// most cases. Renewals created with the default password will + /// automatically change to any future default value, meaning this + /// is also a good practice for maintainability. + /// </summary> + public string? DefaultPassword { get; set; } } public class InstallationSettings 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.lib/Wacs.cs b/src/main.lib/Wacs.cs index 8aeca85..be8faf9 100644 --- a/src/main.lib/Wacs.cs +++ b/src/main.lib/Wacs.cs @@ -235,20 +235,20 @@ namespace PKISharp.WACS.Host { var total = _renewalStore.Renewals.Count(); var due = _renewalStore.Renewals.Count(x => x.IsDue()); - var error = _renewalStore.Renewals.Count(x => !x.History.Last().Success); + var error = _renewalStore.Renewals.Count(x => !x.History.LastOrDefault()?.Success ?? false); var (allowIIS, allowIISReason) = _userRoleService.AllowIIS; var options = new List<Choice<Func<Task>>> { Choice.Create<Func<Task>>( () => _renewalCreator.SetupRenewal(RunLevel.Interactive | RunLevel.Simple), - "Create new certificate (default settings)", "N", + "Create certificate (default settings)", "N", @default: true), Choice.Create<Func<Task>>( - () => _renewalCreator.SetupRenewal(RunLevel.Interactive | RunLevel.Advanced), - "Create new certificate (full options)", "M"), + () => _renewalCreator.SetupRenewal(RunLevel.Interactive | RunLevel.Advanced), + "Create certificate (full options)", "M"), Choice.Create<Func<Task>>( () => _renewalManager.CheckRenewals(RunLevel.Interactive), - $"Run scheduled renewals ({due} currently due)", "R", + $"Run renewals ({due} currently due)", "R", color: due == 0 ? (ConsoleColor?)null : ConsoleColor.Yellow), Choice.Create<Func<Task>>( () => _renewalManager.ManageRenewals(), @@ -341,13 +341,15 @@ namespace PKISharp.WACS.Host "use this tools to temporarily unprotect your data before moving from the old machine. " + "The renewal files includes passwords for your certificates, other passwords/keys, and a key used " + "for signing requests for new certificates."); - _input.Show(null, "To remove machine-dependent protections, use the following steps.", true); + _input.CreateSpace(); + _input.Show(null, "To remove machine-dependent protections, use the following steps."); _input.Show(null, " 1. On your old machine, set the EncryptConfig setting to false"); _input.Show(null, " 2. Run this option; all protected values will be unprotected."); _input.Show(null, " 3. Copy your data files to the new machine."); _input.Show(null, " 4. On the new machine, set the EncryptConfig setting to true"); _input.Show(null, " 5. Run this option; all unprotected values will be saved with protection"); - _input.Show(null, $"Data directory: {settings.Client.ConfigurationPath}", true); + _input.CreateSpace(); + _input.Show(null, $"Data directory: {settings.Client.ConfigurationPath}"); _input.Show(null, $"Config directory: {new FileInfo(settings.ExePath).Directory.FullName}\\settings.json"); _input.Show(null, $"Current EncryptConfig setting: {encryptConfig}"); userApproved = await _input.PromptYesNo($"Save all renewal files {(encryptConfig ? "with" : "without")} encryption?", false); @@ -378,7 +380,8 @@ namespace PKISharp.WACS.Host { throw new InvalidOperationException(); } - _input.Show("Account ID", acmeAccount.Payload.Id ?? "-", true); + _input.CreateSpace(); + _input.Show("Account ID", acmeAccount.Payload.Id ?? "-"); _input.Show("Created", acmeAccount.Payload.CreatedAt); _input.Show("Initial IP", acmeAccount.Payload.InitialIp); _input.Show("Status", acmeAccount.Payload.Status); diff --git a/src/main.lib/wacs.lib.csproj b/src/main.lib/wacs.lib.csproj index 1a56301..ddb25ba 100644 --- a/src/main.lib/wacs.lib.csproj +++ b/src/main.lib/wacs.lib.csproj @@ -23,17 +23,17 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Autofac" Version="5.1.2" /> - <PackageReference Include="DnsClient" Version="1.3.0" /> - <PackageReference Include="MailKit" Version="2.5.2" /> - <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.3" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.3" /> - <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.3" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.3" /> + <PackageReference Include="Autofac" Version="5.2.0" /> + <PackageReference Include="DnsClient" Version="1.3.2" /> + <PackageReference Include="MailKit" Version="2.7.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.4" /> <PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" /> <PackageReference Include="Microsoft.Win32.Registry" Version="4.7.0" /> <PackageReference Include="Nager.PublicSuffix" Version="1.5.1" /> - <PackageReference Include="Portable.BouncyCastle" Version="1.8.6" /> + <PackageReference Include="Portable.BouncyCastle" Version="1.8.6.7" /> <PackageReference Include="Serilog" Version="2.9.0" /> <PackageReference Include="Serilog.Settings.AppSettings" Version="2.2.2" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" /> @@ -48,7 +48,7 @@ <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.7.0" /> <PackageReference Include="System.Security.Cryptography.X509Certificates" Version="4.3.2" /> <PackageReference Include="TaskScheduler" Version="2.8.18" /> - <PackageReference Include="WebDav.Client" Version="2.4.0" /> + <PackageReference Include="WebDav.Client" Version="2.6.0" /> </ItemGroup> <ItemGroup> @@ -56,4 +56,10 @@ <ProjectReference Include="..\fluent-command-line-parser\FluentCommandLineParser\FluentCommandLineParser.csproj" /> </ItemGroup> + <ItemGroup> + <Folder Include="Plugins\OrderPlugins\Domain\" /> + <Folder Include="Plugins\OrderPlugins\Site\" /> + <Folder Include="Plugins\StorePlugins\PfxFile\" /> + </ItemGroup> + </Project> diff --git a/src/main.test/Mock/MockContainer.cs b/src/main.test/Mock/MockContainer.cs new file mode 100644 index 0000000..76560ae --- /dev/null +++ b/src/main.test/Mock/MockContainer.cs @@ -0,0 +1,68 @@ +using Autofac; +using PKISharp.WACS.Clients; +using PKISharp.WACS.Clients.Acme; +using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Clients.IIS; +using PKISharp.WACS.Configuration; +using PKISharp.WACS.Plugins.Resolvers; +using System.Collections.Generic; +using mock = PKISharp.WACS.UnitTests.Mock.Services; +using real = PKISharp.WACS.Services; + +namespace PKISharp.WACS.UnitTests.Mock +{ + class MockContainer + { + public ILifetimeScope TestScope() + { + var log = new mock.LogService(false); + var pluginService = new real.PluginService(log); + var argumentsParser = new ArgumentsParser(log, pluginService, $"".Split(' ')); + var argumentsService = new real.ArgumentsService(log, argumentsParser); + var input = new mock.InputService(new List<string>() + { + "C", // Cancel command + "y", // Confirm cancel all + "Q" // Quit + }); + + var builder = new ContainerBuilder(); + _ = builder.RegisterInstance(log).As<real.ILogService>(); + _ = builder.RegisterInstance(argumentsParser).As<ArgumentsParser>(); + _ = builder.RegisterInstance(argumentsService).As<real.IArgumentsService>(); + _ = builder.RegisterInstance(argumentsService).As<real.IArgumentsService>(); + _ = builder.RegisterInstance(pluginService).As<real.IPluginService>(); + _ = builder.RegisterInstance(input).As<real.IInputService>(); + + _ = builder.RegisterType<mock.MockRenewalStore>().As<real.IRenewalStore>().SingleInstance(); + _ = builder.RegisterType<mock.MockSettingsService>().As<real.ISettingsService>().SingleInstance(); ; + _ = builder.RegisterType<mock.UserRoleService>().As<real.IUserRoleService>().SingleInstance(); + _ = builder.RegisterType<real.ProxyService>().SingleInstance(); + _ = builder.RegisterType<real.PasswordGenerator>().SingleInstance(); + + pluginService.Configure(builder); + + _ = builder.RegisterType<real.DomainParseService>().SingleInstance(); + _ = builder.RegisterType<Mock.Clients.MockIISClient>().As<IIISClient>().SingleInstance(); + _ = builder.RegisterType<IISHelper>().SingleInstance(); + _ = builder.RegisterType<real.ExceptionHandler>().SingleInstance(); + _ = builder.RegisterType<UnattendedResolver>(); + _ = builder.RegisterType<InteractiveResolver>(); + _ = builder.RegisterType<real.AutofacBuilder>().As<real.IAutofacBuilder>().SingleInstance(); + _ = builder.RegisterType<AcmeClient>().SingleInstance(); + _ = builder.RegisterType<real.PemService>().SingleInstance(); + _ = builder.RegisterType<EmailClient>().SingleInstance(); + _ = builder.RegisterType<ScriptClient>().SingleInstance(); + _ = builder.RegisterType<LookupClientProvider>().SingleInstance(); + _ = 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(); + + return builder.Build(); + } + } +} diff --git a/src/main.test/Mock/Services/InputService.cs b/src/main.test/Mock/Services/InputService.cs index 8fc5561..328f0f5 100644 --- a/src/main.test/Mock/Services/InputService.cs +++ b/src/main.test/Mock/Services/InputService.cs @@ -23,7 +23,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services { return Task. FromResult(default(TResult)); - } + } else { return Task. @@ -34,7 +34,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services } public Task<TResult> ChooseRequired<TSource, TResult>( string what, - IEnumerable<TSource> options, + IEnumerable<TSource> options, Func<TSource, Choice<TResult>> creator) { var input = GetNextInput(); @@ -52,7 +52,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services public Task<string?> ReadPassword(string what) => Task.FromResult<string?>(GetNextInput()); public Task<string> RequestString(string what) => Task.FromResult(GetNextInput()); public Task<string> RequestString(string[] what) => Task.FromResult(GetNextInput()); - public void Show(string? label, string? value = null, bool first = false, int level = 0) { } + public void Show(string? label, string? value = null, int level = 0) { } public Task<bool> Wait(string message = "") => Task.FromResult(true); public Task WritePagedList(IEnumerable<Choice> listItems) => Task.CompletedTask; public Task<TResult> ChooseFromMenu<TResult>(string what, List<Choice<TResult>> choices, Func<string, Choice<TResult>>? unexpected = null) @@ -69,5 +69,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services } throw new Exception(); } + + public void CreateSpace() { } } } diff --git a/src/main.test/Mock/Services/LogService.cs b/src/main.test/Mock/Services/LogService.cs index be9ea4c..a2bb11d 100644 --- a/src/main.test/Mock/Services/LogService.cs +++ b/src/main.test/Mock/Services/LogService.cs @@ -3,6 +3,7 @@ using Serilog; using Serilog.Core; using System; using System.Collections.Concurrent; +using System.Collections.Generic; namespace PKISharp.WACS.UnitTests.Mock.Services { @@ -26,6 +27,9 @@ namespace PKISharp.WACS.UnitTests.Mock.Services } public bool Dirty { get; set; } + + public IEnumerable<MemoryEntry> Lines => new List<MemoryEntry>(); + public void Debug(string message, params object?[] items) { DebugMessages.Enqueue(message); @@ -75,5 +79,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services WarningMessages.Enqueue(message); _logger.Warning(message, items); } + + public void Reset() { } } } 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/DnsValidationTests/When_resolving_name_servers.cs b/src/main.test/Tests/DnsValidationTests/When_resolving_name_servers.cs index 7373f09..b8d6427 100644 --- a/src/main.test/Tests/DnsValidationTests/When_resolving_name_servers.cs +++ b/src/main.test/Tests/DnsValidationTests/When_resolving_name_servers.cs @@ -30,26 +30,26 @@ namespace PKISharp.WACS.UnitTests.Tests.DnsValidationTests public void Should_recursively_follow_cnames(string challengeUri, string expectedToken) { //var client = _dnsClient.DefaultClient(); - var client = _dnsClient.GetClients(challengeUri).Result.First(); - var (tokens, cname) = client.GetTextRecordValues(challengeUri, 0).Result; + var auth = _dnsClient.GetAuthority(challengeUri).Result; + Assert.AreEqual(auth.Domain, "_acme-challenge.logs.hourstrackercloud.com"); + var tokens = auth.Nameservers.First().GetTxtRecords(challengeUri).Result; Assert.IsTrue(tokens.Contains(expectedToken)); - Assert.AreEqual(cname, "_acme-challenge.logs.hourstrackercloud.com"); } [TestMethod] [DataRow("activesync.dynu.net")] [DataRow("tweakers.net")] - public void Should_find_nameserver(string domain) => _ = _dnsClient.GetClients(domain).Result; + public void Should_find_nameserver(string domain) => _ = _dnsClient.GetAuthority(domain).Result; [TestMethod] [DataRow("_acme-challenge.acmedns.wouter.tinus.online")] public void Should_Find_Txt(string domain) { - var client = _dnsClient.GetClients(domain).Result.First(); - var (tokens, cname) = client.GetTextRecordValues(domain, 0).Result; + var auth = _dnsClient.GetAuthority(domain).Result; + var tokens = auth.Nameservers.First().GetTxtRecords(auth.Domain).Result; Assert.IsTrue(tokens.Any()); - Assert.AreEqual(cname, "86af4f7c-b82c-4b7d-a75b-3feafbabbb2e.auth.acme-dns.io."); + Assert.AreEqual(auth.Domain, "86af4f7c-b82c-4b7d-a75b-3feafbabbb2e.auth.acme-dns.io"); } } } diff --git a/src/main.test/Tests/EcnryptionTests/EncryptionTest.cs b/src/main.test/Tests/EncryptionTests/EncryptionTest.cs index d370eb0..d370eb0 100644 --- a/src/main.test/Tests/EcnryptionTests/EncryptionTest.cs +++ b/src/main.test/Tests/EncryptionTests/EncryptionTest.cs diff --git a/src/main.test/Tests/InstallationPluginTests/ArgumentParserTests.cs b/src/main.test/Tests/InstallationPluginTests/ArgumentParserTests.cs index debcad1..7a6110a 100644 --- a/src/main.test/Tests/InstallationPluginTests/ArgumentParserTests.cs +++ b/src/main.test/Tests/InstallationPluginTests/ArgumentParserTests.cs @@ -3,6 +3,7 @@ using PKISharp.WACS.Configuration; using PKISharp.WACS.Plugins.InstallationPlugins; using PKISharp.WACS.Services; using PKISharp.WACS.UnitTests.Mock.Services; +using System; namespace PKISharp.WACS.UnitTests.Tests.InstallationPluginTests { @@ -21,11 +22,12 @@ namespace PKISharp.WACS.UnitTests.Tests.InstallationPluginTests $"--scriptparameters {parameters} --verbose".Split(' ')); var argService = new ArgumentsService(log, argParser); var args = argService.GetArguments<ScriptArguments>(); - return args.ScriptParameters; + return args?.ScriptParameters; } [TestMethod] - public void Illegal() => Assert.AreEqual(null, TestScript("hello nonsense")); + [ExpectedException(typeof(Exception))] + public void Illegal() => TestScript("hello nonsense"); [TestMethod] public void SingleParam() => Assert.AreEqual("hello", TestScript("hello")); diff --git a/src/main.test/Tests/OrderPluginTests/DomainPluginTests.cs b/src/main.test/Tests/OrderPluginTests/DomainPluginTests.cs new file mode 100644 index 0000000..b002769 --- /dev/null +++ b/src/main.test/Tests/OrderPluginTests/DomainPluginTests.cs @@ -0,0 +1,24 @@ +using Autofac; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.OrderPlugins; +using PKISharp.WACS.UnitTests.Mock; + +namespace PKISharp.WACS.UnitTests.Tests.EcnryptionTests +{ + [TestClass] + public class DomainPluginTests + { + [TestMethod] + public void DomainSplit() + { + var parts = new TargetPart[] { new TargetPart(new[] { "x.com" }) }; + var target = new Target("x.com", "x.com", parts); + var renewal = new Renewal(); + var container = new MockContainer().TestScope(); + var domain = container.Resolve<Domain>(); + var split = domain.Split(renewal, target); + Assert.IsNotNull(split); + } + } +} diff --git a/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs b/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs index 9ab8c90..6c17610 100644 --- a/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs +++ b/src/main.test/Tests/RenewalTests/RenewalServiceTests.cs @@ -10,6 +10,7 @@ using PKISharp.WACS.Clients; using PKISharp.WACS.Clients.DNS; using System.Linq; using System.Collections.Generic; +using PKISharp.WACS.UnitTests.Mock; namespace PKISharp.WACS.UnitTests.Tests.RenewalTests { @@ -19,56 +20,13 @@ namespace PKISharp.WACS.UnitTests.Tests.RenewalTests [TestMethod] public void Simple() { - var log = new mock.LogService(false); - var pluginService = new real.PluginService(log); - var argumentsParser = new ArgumentsParser(log, pluginService, $"".Split(' ')); - var argumentsService = new real.ArgumentsService(log, argumentsParser); - var input = new mock.InputService(new List<string>() - { - "C", // Cancel command - "y", // Confirm cancel all - "Q" // Quit - }); - - var builder = new ContainerBuilder(); - _ = builder.RegisterInstance(log).As<real.ILogService>(); - _ = builder.RegisterInstance(argumentsParser).As<ArgumentsParser>(); - _ = builder.RegisterInstance(argumentsService).As<real.IArgumentsService>(); - _ = builder.RegisterInstance(argumentsService).As<real.IArgumentsService>(); - _ = builder.RegisterInstance(pluginService).As<real.IPluginService>(); - _ = builder.RegisterInstance(input).As<real.IInputService>(); - - _ = builder.RegisterType<mock.MockRenewalStore>().As<real.IRenewalStore>().SingleInstance(); - _ = builder.RegisterType<mock.MockSettingsService>().As<real.ISettingsService>().SingleInstance(); ; - _ = builder.RegisterType<mock.UserRoleService>().As<real.IUserRoleService>().SingleInstance(); - _ = builder.RegisterType<real.ProxyService>().SingleInstance(); - _ = builder.RegisterType<real.PasswordGenerator>().SingleInstance(); - - pluginService.Configure(builder); - - _ = builder.RegisterType<real.DomainParseService>().SingleInstance(); - _ = builder.RegisterType<Mock.Clients.MockIISClient>().As<IIISClient>().SingleInstance(); - _ = builder.RegisterType<IISHelper>().SingleInstance(); - _ = builder.RegisterType<real.ExceptionHandler>().SingleInstance(); - _ = builder.RegisterType<UnattendedResolver>(); - _ = builder.RegisterType<InteractiveResolver>(); - _ = builder.RegisterType<real.AutofacBuilder>().As<real.IAutofacBuilder>().SingleInstance(); - _ = builder.RegisterType<AcmeClient>().SingleInstance(); - _ = builder.RegisterType<real.PemService>().SingleInstance(); - _ = builder.RegisterType<EmailClient>().SingleInstance(); - _ = builder.RegisterType<ScriptClient>().SingleInstance(); - _ = builder.RegisterType<LookupClientProvider>().SingleInstance(); - _ = builder.RegisterType<mock.CertificateService>().As<real.ICertificateService>().SingleInstance(); - _ = builder.RegisterType<real.TaskSchedulerService>().SingleInstance(); - _ = builder.RegisterType<real.NotificationService>().SingleInstance(); - _ = builder.RegisterType<RenewalExecutor>().SingleInstance(); - _ = builder.RegisterType<RenewalManager>().SingleInstance(); - _ = builder.Register(c => c.Resolve<real.IArgumentsService>().MainArguments).SingleInstance(); - - var container = builder.Build(); + 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.test/wacs.test.csproj b/src/main.test/wacs.test.csproj index 1cdbb54..35e2c74 100644 --- a/src/main.test/wacs.test.csproj +++ b/src/main.test/wacs.test.csproj @@ -15,10 +15,10 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> - <PackageReference Include="MSTest.TestAdapter" Version="2.1.0" /> - <PackageReference Include="MSTest.TestFramework" Version="2.1.0" /> - <PackageReference Include="coverlet.collector" Version="1.2.0"> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> + <PackageReference Include="MSTest.TestAdapter" Version="2.1.1" /> + <PackageReference Include="MSTest.TestFramework" Version="2.1.1" /> + <PackageReference Include="coverlet.collector" Version="1.3.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/src/main/Program.cs b/src/main/Program.cs index 6237e56..e02a212 100644 --- a/src/main/Program.cs +++ b/src/main/Program.cs @@ -110,7 +110,7 @@ namespace PKISharp.WACS.Host pluginService.Configure(builder); _ = builder.RegisterType<DomainParseService>().SingleInstance(); - _ = builder.RegisterType<IISClient>().As<IIISClient>().SingleInstance(); + _ = builder.RegisterType<IISClient>().As<IIISClient>().InstancePerLifetimeScope(); _ = builder.RegisterType<IISHelper>().SingleInstance(); _ = builder.RegisterType<ExceptionHandler>().SingleInstance(); _ = builder.RegisterType<UnattendedResolver>(); @@ -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 7657b14..de50241 100644 --- a/src/main/settings.json +++ b/src/main/settings.json @@ -59,8 +59,9 @@ "DefaultTarget": null }, "Validation": { - "DefaultValidation": "script", - "DefaultValidationMode": "dns-01", + "DefaultValidation": null, + "DefaultValidationMode": null, + "DisableMultiThreading": false, "CleanupFolders": true, "PreValidateDns": true, "PreValidateDnsRetryCount": 5, @@ -76,10 +77,20 @@ }, "Store": { "DefaultStore": null, - "DefaultCertificateStore": null, - "DefaultCentralSslStore": null, - "DefaultCentralSslPfxPassword": null, - "DefaultPemFilesPath": null + "CertificateStore": { + "DefaultStore": null + }, + "CentralSsl": { + "DefaultPath": null, + "DefaultPassword": null + }, + "PemFiles": { + "DefaultPath": null + }, + "PfxFile": { + "DefaultPath": null, + "DefaultPassword": null + } }, "Installation": { "DefaultInstallation": null diff --git a/src/plugin.validation.dns.azure/Azure.cs b/src/plugin.validation.dns.azure/Azure.cs index ce9d2ab..b4d053e 100644 --- a/src/plugin.validation.dns.azure/Azure.cs +++ b/src/plugin.validation.dns.azure/Azure.cs @@ -4,33 +4,36 @@ 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 Uri _resourceManagerEndpoint; 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>>(); _resourceManagerEndpoint = new Uri(AzureEnvironments.ResourceManagerUrls[AzureEnvironments.AzureCloud]); if (!string.IsNullOrEmpty(options.AzureEnvironment)) { @@ -50,32 +53,117 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns } } - public override async Task 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); - if (string.IsNullOrEmpty(zone)) + var zone = await GetHostedZone(record.Authority.Domain); + if (zone == null) { - return; + 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; + } - var subDomain = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.'); - - // 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 = 3600, - TxtRecords = new List<TxtRecord> + foreach (var domain in _recordSets[zone].Keys) { - new TxtRecord(new[] { token }) + updateTasks.Add(CreateOrUpdateRecordSet(zone, domain)); } - }; + } + await Task.WhenAll(updateTasks); + } - _ = await client.RecordSets.CreateOrUpdateAsync(_options.ResourceGroupName, - zone, - subDomain, - RecordType.TXT, - recordSetParams); + /// <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 + { + 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 DNS records in {zone} ({domain})", zone, domain); + } } private async Task<DnsManagementClient> GetClient() @@ -122,59 +210,65 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns }; } - private async Task<string> GetHostedZone(string recordName) + /// <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 client = await GetClient(); - var domainName = _domainParser.GetDomain(recordName); - 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, _options.ResourceGroupName); + var ret = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.'); + return string.IsNullOrEmpty(ret) ? "@" : ret; + } - var hostedZone = zones.Select(zone => + /// <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) + { + // Cache so we don't have to repeat this more than once for each renewal + if (_hostedZones == null) { - var fit = 0; - var name = zone.Name.TrimEnd('.').ToLowerInvariant(); - if (recordName.ToLowerInvariant().EndsWith(name)) + 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)) { - // If there is a zone for a.b.c.com (4) and one for c.com (2) - // then the former is a better (more specific) match than the - // latter, so we should use that - fit = name.Split('.').Count(); + response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink); } - return new { zone, fit }; - }). - Where(x => x.fit > 0). - OrderByDescending(x => x.fit). - FirstOrDefault(); + _log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones.Count, _options.ResourceGroupName); + _hostedZones = zones; + } + var hostedZone = FindBestMatch(_hostedZones.ToDictionary(x => x.Name), recordName); if (hostedZone != null) { - return hostedZone.zone.Name; + return hostedZone.Name; } - _log.Error( - "Can't find hosted zone for {domainName} in resource group {ResourceGroupName}", - domainName, + "Can't find hosted zone for {recordName} in resource group {ResourceGroupName}", + recordName, _options.ResourceGroupName); - return null; } - public override async Task DeleteRecord(string recordName, string token) - { - var client = await GetClient(); - var zone = await GetHostedZone(recordName); - var subDomain = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.'); - await client.RecordSets.DeleteAsync( - _options.ResourceGroupName, - zone, - subDomain, - RecordType.TXT); - } + /// <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.azure/wacs.validation.dns.azure.csproj b/src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj index efa9caa..b932057 100755 --- a/src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj +++ b/src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj @@ -8,7 +8,7 @@ <ItemGroup> <PackageReference Include="Microsoft.Azure.Management.Dns" Version="3.0.1" /> - <PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.4.0" /> + <PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.5.0" /> <PackageReference Include="Microsoft.Rest.ClientRuntime.Azure.Authentication" Version="2.4.0" /> </ItemGroup> diff --git a/src/plugin.validation.dns.cloudflare/Cloudflare.cs b/src/plugin.validation.dns.cloudflare/Cloudflare.cs index 2937903..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,24 +24,21 @@ 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(); _domainParser = domainParser; } - private IAuthorizedSyntax GetContext() - { + private IAuthorizedSyntax GetContext() => // avoid name collision with this class - return FluentCloudflare.Cloudflare.WithToken(_options.ApiToken.Value); - } + FluentCloudflare.Cloudflare.WithToken(_options.ApiToken.Value); private async Task<Zone> GetHostedZone(IAuthorizedSyntax context, string recordName) { var prs = _domainParser; - var domainName = $"{prs.GetDomain(recordName)}.{prs.GetTLD(recordName)}"; + var domainName = $"{prs.GetRegisterableDomain(recordName)}"; var zonesResp = await context.Zones.List() .WithName(domainName) .ParseAsync(_hc) @@ -48,33 +46,32 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns if (!zonesResp.Success || (zonesResp.Result?.Count ?? 0) < 1) { - _log.Error( - "Zone {domainName} could not be found using the Cloudflare API. Maybe you entered a wrong API Token or domain or the API Token does not allow access to this domain?", - domainName); - // maybe throwing would be better - // this is how the Azure DNS Validator works - return null; + _log.Error("Zone {domainName} could not be found using the Cloudflare API." + + " Maybe you entered a wrong API Token or domain or the API Token does" + + " not allow access to this domain?", domainName); + throw new Exception(); } - return zonesResp.Unpack().First(); } - public override async Task 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) { - throw new InvalidOperationException($"The zone could not be found using the Cloudflare API, thus creating a DNS validation record is impossible. " + + _log.Error("The zone could not be found using the Cloudflare API, thus creating a DNS validation record is impossible. " + $"Please note you need to use an API Token, not the Global API Key. The token needs the permissions Zone.Zone:Read and Zone.DNS:Edit. Regarding " + $"Zone:Read it is important, that this token has access to all zones in your account (Zone Resources > Include > All zones) because we need to " + $"list your zones. Read the docs carefully for instructions."); + return false; } 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; } private async Task DeleteRecord(string recordName, string token, IAuthorizedSyntax context, Zone zone) @@ -90,17 +87,29 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns .ConfigureAwait(false); var record = records.FirstOrDefault(); if (record == null) - throw new Exception($"The record {recordName} that should be deleted does not exist at Cloudflare."); - await dns.Delete(record.Id) - .CallAsync(_hc) - .ConfigureAwait(false); + { + _log.Warning($"The record {recordName} that should be deleted does not exist at Cloudflare."); + return; + } + + try + { + _ = await dns.Delete(record.Id) + .CallAsync(_hc) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _log.Warning($"Unable to delete record from Cloudflare: {ex.Message}"); + } + } - 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/DreamhostArgumentsProvider.cs b/src/plugin.validation.dns.dreamhost/DreamhostArgumentsProvider.cs index 1449635..06a96fb 100644 --- a/src/plugin.validation.dns.dreamhost/DreamhostArgumentsProvider.cs +++ b/src/plugin.validation.dns.dreamhost/DreamhostArgumentsProvider.cs @@ -13,7 +13,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins public override void Configure(FluentCommandLineParser<DreamhostArguments> parser) { - parser.Setup(o => o.ApiKey) + _ = parser.Setup(o => o.ApiKey) .As("apiKey") .WithDescription("Dreamhost API key."); } diff --git a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs index 8c971b5..2638d63 100644 --- a/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs +++ b/src/plugin.validation.dns.dreamhost/DreamhostDnsValidation.cs @@ -1,6 +1,8 @@ using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Context; using PKISharp.WACS.Plugins.ValidationPlugins.Dreamhost; using PKISharp.WACS.Services; +using System; using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins @@ -17,8 +19,29 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins : base(dnsClient, logService, settings) => _client = new DnsManagementClient(options.ApiKey.Value, logService); - public override Task CreateRecord(string recordName, string token) => _client.CreateRecord(recordName, RecordType.TXT, token); + public override async Task<bool> CreateRecord(DnsValidationRecord record) + { + try + { + await _client.CreateRecord(record.Authority.Domain, RecordType.TXT, record.Value); + return true; + } + catch + { + return false; + } + } - public override Task DeleteRecord(string recordName, string token) => _client.DeleteRecord(recordName, RecordType.TXT, token); + public override async Task DeleteRecord(DnsValidationRecord record) + { + try + { + await _client.DeleteRecord(record.Authority.Domain, RecordType.TXT, record.Value); + } + catch (Exception ex) + { + _log.Warning($"Unable to delete record from Dreamhost: {ex.Message}"); + } + } } } diff --git a/src/plugin.validation.dns.luadns/luadns.cs b/src/plugin.validation.dns.luadns/luadns.cs index 2cd561c..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; @@ -12,7 +13,7 @@ using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { - internal sealed class LUADNS : DnsValidation<LUADNS> + internal sealed class LuaDns : DnsValidation<LuaDns> { private class ZoneData { @@ -44,94 +45,83 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns public int TTL { get; set; } } - private static readonly Uri _luaDNSApiEndpoint = new Uri("https://api.luadns.com/v1/", UriKind.Absolute); + private static readonly Uri _LuaDnsApiEndpoint = new Uri("https://api.luadns.com/v1/", UriKind.Absolute); private static readonly Dictionary<string, RecordData> _recordsMap = new Dictionary<string, RecordData>(); - private readonly DomainParseService _domainParser; private readonly ProxyService _proxyService; private readonly string _userName; private readonly string _apiKey; - public LUADNS( + public LuaDns( LookupClientProvider dnsClient, - DomainParseService domainParser, ProxyService proxy, ILogService log, ISettingsService settings, - LUADNSOptions options) - : base(dnsClient, log, settings) + LuaDnsOptions options): base(dnsClient, log, settings) { - _domainParser = domainParser; _proxyService = proxy; - _userName = options.Username; _apiKey = options.APIKey.Value; } - public override async Task CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { - _log.Information("Creating LUADNS verification record"); + _log.Information("Creating LuaDNS verification record"); + + using var client = GetClient(); + var response = await client.GetAsync(new Uri(_LuaDnsApiEndpoint, "zones")); + if (!response.IsSuccessStatusCode) + { + _log.Error("Failed to get DNS zones list for account. Aborting."); + return false; + } - using (var client = GetClient()) + var payload = await response.Content.ReadAsStringAsync(); + var zones = JsonSerializer.Deserialize<ZoneData[]>(payload); + var targetZone = FindBestMatch(zones.ToDictionary(x => x.Name), record.Authority.Domain); + if (targetZone == null) { - var response = await client.GetAsync(new Uri(_luaDNSApiEndpoint, "zones")); - if (!response.IsSuccessStatusCode) - { - _log.Information("Failed to get DNS zones list for account. Aborting."); - return; - } - - var payload = await response.Content.ReadAsStringAsync(); - var zones = JsonSerializer.Deserialize<ZoneData[]>(payload); - var targetZone = zones.Where(d => recordName.EndsWith(d.Name, StringComparison.InvariantCultureIgnoreCase)).OrderByDescending(d => d.Name.Length).FirstOrDefault(); - if (targetZone == null) - { - _log.Information("No matching zone found in LUADNS account. Aborting"); - return; - } - - var newRecord = new RecordData { Name = $"{recordName}.", Type = "TXT", Content = token, TTL = 300 }; - payload = JsonSerializer.Serialize(newRecord); - - response = await client.PostAsync(new Uri(_luaDNSApiEndpoint, $"zones/{targetZone.Id}/records"), new StringContent(payload, Encoding.UTF8, "application/json")); - if (!response.IsSuccessStatusCode) - { - _log.Information("Failed to create DNS verification record"); - return; - } - - payload = await response.Content.ReadAsStringAsync(); - newRecord = JsonSerializer.Deserialize<RecordData>(payload); - - _recordsMap[recordName] = newRecord; - - _log.Information("DNS Record created. Waiting 30 seconds to allow propagation."); - await Task.Delay(30000); + _log.Error("No matching zone found in LuaDNS account. Aborting"); + return false; } + + 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")); + if (!response.IsSuccessStatusCode) + { + _log.Error("Failed to create DNS verification record"); + return false; + } + + payload = await response.Content.ReadAsStringAsync(); + newRecord = JsonSerializer.Deserialize<RecordData>(payload); + _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.Information($"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"); + _log.Information("Deleting LuaDNS verification record"); - using (var client = GetClient()) + using var client = GetClient(); + var created = _recordsMap[record.Authority.Domain]; + var response = await client.DeleteAsync(new Uri(_LuaDnsApiEndpoint, $"zones/{created.ZoneId}/records/{created.Id}")); + if (!response.IsSuccessStatusCode) { - var response = await client.DeleteAsync(new Uri(_luaDNSApiEndpoint, $"zones/{_recordsMap[recordName].ZoneId}/records/{_recordsMap[recordName].Id}")); - if (!response.IsSuccessStatusCode) - { - _log.Information("Failed to delete DNS verification record"); - return; - } - - _recordsMap.Remove(recordName); + _log.Warning("Failed to delete DNS verification record"); + return; } + + _ = _recordsMap.Remove(record.Authority.Domain); } private HttpClient GetClient() diff --git a/src/plugin.validation.dns.luadns/luadnsArguments.cs b/src/plugin.validation.dns.luadns/luadnsArguments.cs index 13ab07d..cdb4dea 100644 --- a/src/plugin.validation.dns.luadns/luadnsArguments.cs +++ b/src/plugin.validation.dns.luadns/luadnsArguments.cs @@ -1,8 +1,8 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { - public sealed class LUADNSArguments + public sealed class LuaDnsArguments { - public string LUADNSUsername { get; set; } - public string LUADNSAPIKey { get; set; } + public string LuaDnsUsername { get; set; } + public string LuaDnsAPIKey { get; set; } } }
\ No newline at end of file diff --git a/src/plugin.validation.dns.luadns/luadnsArgumentsProvider.cs b/src/plugin.validation.dns.luadns/luadnsArgumentsProvider.cs index 9cdf9e6..5befdb3 100644 --- a/src/plugin.validation.dns.luadns/luadnsArgumentsProvider.cs +++ b/src/plugin.validation.dns.luadns/luadnsArgumentsProvider.cs @@ -3,20 +3,20 @@ using PKISharp.WACS.Configuration; namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { - public sealed class LUADNSArgumentsProvider : BaseArgumentsProvider<LUADNSArguments> + public sealed class LuaDnsArgumentsProvider : BaseArgumentsProvider<LuaDnsArguments> { - public override string Name { get; } = "LUADNS"; + public override string Name { get; } = "LuaDns"; public override string Group { get; } = "Validation"; - public override string Condition { get; } = "--validationmode dns-01 --validation luadns"; - public override void Configure(FluentCommandLineParser<LUADNSArguments> parser) + public override string Condition { get; } = "--validationmode dns-01 --validation LuaDns"; + public override void Configure(FluentCommandLineParser<LuaDnsArguments> parser) { - parser.Setup(_ => _.LUADNSUsername) - .As("LUADNSUsername") - .WithDescription("LUADN account useername (email address)"); + _ = parser.Setup(_ => _.LuaDnsUsername) + .As("LuaDnsUsername") + .WithDescription("LuaDNS account username (email address)"); - parser.Setup(_ => _.LUADNSAPIKey) - .As("LUADNSAPIKey") - .WithDescription("LUADNS API Key"); + _ = parser.Setup(_ => _.LuaDnsAPIKey) + .As("LuaDnsAPIKey") + .WithDescription("LuaDNS API key"); } } }
\ No newline at end of file diff --git a/src/plugin.validation.dns.luadns/luadnsOptions.cs b/src/plugin.validation.dns.luadns/luadnsOptions.cs index 348b4bd..1804774 100644 --- a/src/plugin.validation.dns.luadns/luadnsOptions.cs +++ b/src/plugin.validation.dns.luadns/luadnsOptions.cs @@ -6,10 +6,10 @@ using PKISharp.WACS.Services.Serialization; namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { [Plugin("3b0c3cca-db98-40b7-b678-b34791070d42")] - internal sealed class LUADNSOptions : ValidationPluginOptions<LUADNS> + internal sealed class LuaDnsOptions : ValidationPluginOptions<LuaDns> { - public override string Name { get; } = "LUADNS"; - public override string Description { get; } = "Create verification records in LUADNS"; + public override string Name { get; } = "LuaDns"; + public override string Description { get; } = "Create verification records in LuaDns"; public override string ChallengeType { get; } = Constants.Dns01ChallengeType; public string Username { get; set; } diff --git a/src/plugin.validation.dns.luadns/luadnsOptionsFactory.cs b/src/plugin.validation.dns.luadns/luadnsOptionsFactory.cs index c6357cd..95d4384 100644 --- a/src/plugin.validation.dns.luadns/luadnsOptionsFactory.cs +++ b/src/plugin.validation.dns.luadns/luadnsOptionsFactory.cs @@ -7,29 +7,29 @@ using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns { - internal sealed class LUADNSOptionsFactory : ValidationPluginOptionsFactory<LUADNS, LUADNSOptions> + internal sealed class LuaDnsOptionsFactory : ValidationPluginOptionsFactory<LuaDns, LuaDnsOptions> { private readonly IArgumentsService _arguments; - public LUADNSOptionsFactory(IArgumentsService arguments) : base(Dns01ChallengeValidationDetails.Dns01ChallengeType) => _arguments = arguments; + public LuaDnsOptionsFactory(IArgumentsService arguments) : base(Dns01ChallengeValidationDetails.Dns01ChallengeType) => _arguments = arguments; - public override async Task<LUADNSOptions> Aquire(Target target, IInputService input, RunLevel runLevel) + public override async Task<LuaDnsOptions> Aquire(Target target, IInputService input, RunLevel runLevel) { - var args = _arguments.GetArguments<LUADNSArguments>(); - var opts = new LUADNSOptions + var args = _arguments.GetArguments<LuaDnsArguments>(); + var opts = new LuaDnsOptions { - Username = await _arguments.TryGetArgument(args.LUADNSUsername, input, "LUADNS Account username"), - APIKey = new ProtectedString(await _arguments.TryGetArgument(args.LUADNSAPIKey, input, "LUADNS API key", true)) + Username = await _arguments.TryGetArgument(args.LuaDnsUsername, input, "LuaDns Account username"), + APIKey = new ProtectedString(await _arguments.TryGetArgument(args.LuaDnsAPIKey, input, "LuaDns API key", true)) }; return opts; } - public override Task<LUADNSOptions> Default(Target target) + public override Task<LuaDnsOptions> Default(Target target) { - var args = _arguments.GetArguments<LUADNSArguments>(); - var opts = new LUADNSOptions + var args = _arguments.GetArguments<LuaDnsArguments>(); + var opts = new LuaDnsOptions { - Username = _arguments.TryGetRequiredArgument(nameof(args.LUADNSUsername), args.LUADNSUsername), - APIKey = new ProtectedString(_arguments.TryGetRequiredArgument(nameof(args.LUADNSAPIKey), args.LUADNSAPIKey)) + Username = _arguments.TryGetRequiredArgument(nameof(args.LuaDnsUsername), args.LuaDnsUsername), + APIKey = new ProtectedString(_arguments.TryGetRequiredArgument(nameof(args.LuaDnsAPIKey), args.LuaDnsAPIKey)) }; return Task.FromResult(opts); } diff --git a/src/plugin.validation.dns.luadns/wacs.validation.dns.luadns.csproj b/src/plugin.validation.dns.luadns/wacs.validation.dns.luadns.csproj index 3749682..740a4c6 100644 --- a/src/plugin.validation.dns.luadns/wacs.validation.dns.luadns.csproj +++ b/src/plugin.validation.dns.luadns/wacs.validation.dns.luadns.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <TargetFramework>netcoreapp3.1</TargetFramework> - <AssemblyName>PKISharp.WACS.Plugins.ValidationPlugins.LUADNS</AssemblyName> + <AssemblyName>PKISharp.WACS.Plugins.ValidationPlugins.LuaDns</AssemblyName> <RootNamespace>PKISharp.WACS.Plugins.ValidationPlugins</RootNamespace> </PropertyGroup> diff --git a/src/plugin.validation.dns.route53/Route53.cs b/src/plugin.validation.dns.route53/Route53.cs index 168d734..38aae55 100644 --- a/src/plugin.validation.dns.route53/Route53.cs +++ b/src/plugin.validation.dns.route53/Route53.cs @@ -3,7 +3,9 @@ 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; @@ -13,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 }; @@ -32,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) @@ -47,84 +45,79 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns }; } - public override async Task CreateRecord(string recordName, string token) + public override async Task<bool> CreateRecord(DnsValidationRecord record) { - var hostedZoneId = await GetHostedZoneId(recordName); - if (hostedZoneId != null) + try { - _log.Information($"Creating TXT record {recordName} with value {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 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 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) + { + _log.Warning($"Error creating TXT record: {ex.Message}"); + return false; } } - public override async Task DeleteRecord(string recordName, string token) + public override async Task DeleteRecord(DnsValidationRecord record) { - var hostedZoneId = await GetHostedZoneId(recordName); - if (hostedZoneId != null) - { - _log.Information($"Deleting TXT record {recordName} with value {token}"); - _ = await _route53Client.ChangeResourceRecordSetsAsync( + var recordName = record.Authority.Domain; + var token = record.Value; + var hostedZoneIds = await GetHostedZoneIds(recordName); + _log.Information($"Deleting TXT record {recordName} with value {token}"); + var deleteTasks = hostedZoneIds.Select(hostedZoneId => + _route53Client.ChangeResourceRecordSetsAsync( new ChangeResourceRecordSetsRequest(hostedZoneId, new ChangeBatch(new List<Change> { - new Change( - ChangeAction.DELETE, - CreateResourceRecordSet(recordName, token)) - }))); - } + 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 domainName = _domainParser.GetDomain(recordName); var hostedZones = new List<HostedZone>(); var response = await _route53Client.ListHostedZonesAsync(); hostedZones.AddRange(response.HostedZones); while (response.IsTruncated) { response = await _route53Client.ListHostedZonesAsync( - new ListHostedZonesRequest() { - Marker = response.NextMarker + new ListHostedZonesRequest() { + Marker = response.NextMarker }); hostedZones.AddRange(response.HostedZones); } _log.Debug("Found {count} hosted zones in AWS", hostedZones); - - var hostedZone = hostedZones.Select(zone => - { - var fit = 0; - var name = zone.Name.TrimEnd('.').ToLowerInvariant(); - if (recordName.ToLowerInvariant().EndsWith(name)) - { - // If there is a zone for a.b.c.com (4) and one for c.com (2) - // then the former is a better (more specific) match than the - // latter, so we should use that - fit = name.Split('.').Count(); - _log.Verbose("Zone {name} scored {fit} points", zone.Name, fit); - } - else - { - _log.Verbose("Zone {name} not matched", zone.Name); - } - return new { zone, fit }; - }). - Where(x => x.fit > 0). - OrderByDescending(x => x.fit). - FirstOrDefault(); + 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.zone.Id; + return hostedZone.Select(x => x.Id); } - - _log.Error($"Can't find hosted zone for domain {domainName}"); + _log.Error($"Can't find hosted zone for domain {recordName}"); return null; } @@ -141,7 +134,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns while ((await _route53Client.GetChangeAsync(changeRequest)).ChangeInfo.Status == ChangeStatus.PENDING) { - await Task.Delay(5000); + await Task.Delay(2000); } } } diff --git a/src/plugin.validation.dns.route53/wacs.validation.dns.route53.csproj b/src/plugin.validation.dns.route53/wacs.validation.dns.route53.csproj index 0209e91..4640fb5 100644 --- a/src/plugin.validation.dns.route53/wacs.validation.dns.route53.csproj +++ b/src/plugin.validation.dns.route53/wacs.validation.dns.route53.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="AWSSDK.Route53" Version="3.3.102.98" /> + <PackageReference Include="AWSSDK.Route53" Version="3.3.104.12" /> </ItemGroup> <ItemGroup> |