diff options
author | Wouter Tinus <win.acme.simple@gmail.com> | 2020-08-01 22:34:20 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-08-01 22:34:20 +0200 |
commit | c662521de225e4dd4078fc6f0c2d1adfe323e368 (patch) | |
tree | 5a516ef3a315ef3f03a0fa51c3c1edd7613ca3a4 | |
parent | 2738777a456ade0dd8d367d87be9037694759986 (diff) | |
parent | 9559e163dc30ef8fdf95957b79eee9a1010dbdbb (diff) | |
download | letsencrypt-win-simple-c662521de225e4dd4078fc6f0c2d1adfe323e368.zip letsencrypt-win-simple-c662521de225e4dd4078fc6f0c2d1adfe323e368.tar.gz letsencrypt-win-simple-c662521de225e4dd4078fc6f0c2d1adfe323e368.tar.bz2 |
Merge pull request #1635 from win-acme/2.1.10v2.1.10
2.1.10
58 files changed, 2259 insertions, 1667 deletions
diff --git a/appveyor.yml b/appveyor.yml index ff2cc11..36ab92a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 2.1.9.{build} +version: 2.1.10.{build} image: Visual Studio 2019 platform: Any CPU shallow_clone: true @@ -29,6 +29,7 @@ build_script: - 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 + - cmd: dotnet publish ./src/plugin.validation.dns.digitalocean/wacs.validation.dns.digitalocean.csproj -c Release test_script: - cmd: cd %APPVEYOR_BUILD_FOLDER%/src/main.test/ - cmd: nuget install Appveyor.TestLogger -Version 2.0.0 diff --git a/build/build.ps1 b/build/build.ps1 index bd56192..ce137f4 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -27,8 +27,9 @@ $BuildFolder = Join-Path -Path $RepoRoot "build" & dotnet publish $RepoRoot\src\plugin.validation.dns.azure\wacs.validation.dns.azure.csproj -c "Release" & dotnet publish $RepoRoot\src\plugin.validation.dns.cloudflare\wacs.validation.dns.cloudflare.csproj -c "Release" & dotnet publish $RepoRoot\src\plugin.validation.dns.dreamhost\wacs.validation.dns.dreamhost.csproj -c "Release" -& dotnet publish $RepoRoot\src\plugin.validation.dns.route53\wacs.validation.dns.luadns.csproj -c "Release" +& dotnet publish $RepoRoot\src\plugin.validation.dns.luadns\wacs.validation.dns.luadns.csproj -c "Release" & dotnet publish $RepoRoot\src\plugin.validation.dns.route53\wacs.validation.dns.route53.csproj -c "Release" +& dotnet publish $RepoRoot\src\plugin.validation.dns.digitalocean\wacs.validation.dns.digitalocean.csproj -c "Release" if (-not $?) { diff --git a/build/create-artifacts.ps1 b/build/create-artifacts.ps1 index d1d6b92..cb4a936 100644 --- a/build/create-artifacts.ps1 +++ b/build/create-artifacts.ps1 @@ -106,6 +106,10 @@ PluginRelease cloudflare plugin.validation.dns.cloudflare @( "FluentCloudflare.dll", "PKISharp.WACS.Plugins.ValidationPlugins.Cloudflare.dll" ) +PluginRelease cloudflare plugin.validation.dns.digitalocean @( + "DigitalOcean.API.dll", + "PKISharp.WACS.Plugins.ValidationPlugins.DigitalOcean.dll" +) "Created artifacts:" dir $Out
\ No newline at end of file diff --git a/dist/Scripts/ImportADFS.ps1 b/dist/Scripts/ImportADFS.ps1 index f250f28..65b8f3a 100644 --- a/dist/Scripts/ImportADFS.ps1 +++ b/dist/Scripts/ImportADFS.ps1 @@ -21,6 +21,8 @@ The exact thumbprint of the cert to be imported. The script will copy this cert ImportADFS.ps1 <certThumbprint> +./wacs.exe --target manual --host hostname.example.com,adfs.example.com,sts.example.com --installation iis,script --installationsiteid 1 --script ".\Scripts\ImportADFS.ps1" --scriptparameters "'{CertThumbprint}'" --certificatestore My + .NOTES #> diff --git a/dist/Scripts/ImportKemp.ps1 b/dist/Scripts/ImportKemp.ps1 new file mode 100644 index 0000000..c961b21 --- /dev/null +++ b/dist/Scripts/ImportKemp.ps1 @@ -0,0 +1,90 @@ +<#
+.SYNOPSIS
+Imports a cert from WASC renewal into KEMP Loadmaster.
+.DESCRIPTION
+Note that this script is intended to be run via the install script plugin from WASC via the batch script wrapper. As such, we use positional parameters to avoid issues with using a dash in the cmd line.
+
+THIS SCRIPT IS INCOMPLETE AND *mostly* UNTESTED (some modifications have come in from people using it successfully)
+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 CertName
+The exact ID of the cert to be imported.
+
+.PARAMETER PfxFile
+File name in the CertificatePath.
+
+.PARAMETER PfxPassword
+(Central Certificate Store) Password of the .pfx file.
+
+.PARAMETER KempUserName
+Username for KEMP PowerShell module
+
+.PARAMETER KempUserPass
+Password for KEMP PowerShell module
+
+.PARAMETER KempIP
+KEMP IP address
+
+.EXAMPLE
+
+./Scripts/ImportKemp.ps1 "'{RenewalId}' '{CacheFile}' '{CachePassword}' 'bal' 'pass' '10.10.10.10'"
+
+
+.NOTES
+KEMP PowerShell module installation help:
+https://support.kemptechnologies.com/hc/en-us/articles/203863385-PowerShell#MadCap_TOC_8_2
+
+Download site, Tools --> General --> LoadMaster PowerShell API Wrapper: https://kemptechnologies.com/loadmaster-documentation/
+
+#>
+
+param(
+ [Parameter(Position=0,Mandatory=$true)]
+ [string]
+ $CertName,
+
+ [Parameter(Position=1,Mandatory=$false)]
+ [string]
+ $PfxFile,
+
+ [Parameter(Position=2,Mandatory=$false)]
+ [string]
+ $PfxPassword,
+
+ [Parameter(Position=3,Mandatory=$false)]
+ [string]
+ $KempUserName,
+
+ [Parameter(Position=4,Mandatory=$false)]
+ [string]
+ $KempUserPass,
+
+ [Parameter(Position=5,Mandatory=$false)]
+ [string]
+ $KempIP
+)
+
+Import-Module Kemp.LoadBalancer.Powershell
+
+#Get-Module Kemp.LoadBalancer.Powershell
+#Test-LmServerConnection -ComputerName $KempIP -Port 443 -Verbose
+
+$password = ConvertTo-SecureString $KempUserPass -AsPlainText -Force
+$psCred = New-Object System.Management.Automation.PSCredential -ArgumentList ($KempUserName, $password)
+$arrLMConnectResult = Initialize-LmConnectionParameters -Address $KempIP -LBPort 443 -Credential $psCred
+
+#Get-Command -Module Kemp.LoadBalancer.Powershell | Out-GridView
+#(Get-TlsCertificate).Data.cert
+
+$NewTlsCertificateResult = New-TlsCertificate -Name $CertName -Password $PfxPassword -Replace -Path $PfxFile
+if($NewTlsCertificateResult.returncode -eq "422"){
+ $NewTlsCertificateResult = New-TlsCertificate -Name $CertName -Password $PfxPassword -Path $PfxFile
+}
+
+$NewTlsCertificateResult
diff --git a/src/ACMESharpCore b/src/ACMESharpCore -Subproject bd6f0bbc0b0e1cb17303324a2a0b4c657a045ed +Subproject 10ed9ffc9141d7d8e4ca207db4b3c924fad1bb1 diff --git a/src/main.lib/Clients/Acme/AcmeClient.cs b/src/main.lib/Clients/Acme/AcmeClient.cs index f7f2cce..afe276f 100644 --- a/src/main.lib/Clients/Acme/AcmeClient.cs +++ b/src/main.lib/Clients/Acme/AcmeClient.cs @@ -4,6 +4,7 @@ using ACMESharp.Crypto.JOSE.Impl; using ACMESharp.Protocol;
using ACMESharp.Protocol.Resources;
using Newtonsoft.Json;
+using PKISharp.WACS.Configuration;
using PKISharp.WACS.Extensions;
using PKISharp.WACS.Services;
using PKISharp.WACS.Services.Serialization;
@@ -50,6 +51,7 @@ namespace PKISharp.WACS.Clients.Acme private readonly ISettingsService _settings;
private readonly IArgumentsService _arguments;
private readonly ProxyService _proxyService;
+ private readonly AccountArguments _accountArguments;
private AcmeProtocolClient? _client;
private AccountSigner? _accountSigner;
@@ -67,6 +69,7 @@ namespace PKISharp.WACS.Clients.Acme _arguments = arguments;
_input = inputService;
_proxyService = proxy;
+ _accountArguments = _arguments.GetArguments<AccountArguments>() ?? new AccountArguments();
}
#region - Account and registration -
@@ -74,8 +77,8 @@ namespace PKISharp.WACS.Clients.Acme internal async Task ConfigureAcmeClient()
{
_log.Verbose("Loading ACME account signer...");
- IJwsTool? signer = null;
var accountSigner = AccountSigner;
+ IJwsTool? signer = null;
if (accountSigner != null)
{
signer = accountSigner.JwsTool();
@@ -102,6 +105,7 @@ namespace PKISharp.WACS.Clients.Acme throw new Exception("AcmeClient was unable to find or create an account");
}
_client = client;
+ _log.Verbose("ACME client initialized");
}
internal AcmeProtocolClient PrepareClient(HttpClient httpClient, IJwsTool? signer)
@@ -160,6 +164,7 @@ namespace PKISharp.WACS.Clients.Acme private async Task<AccountDetails?> LoadAccount(AcmeProtocolClient client, IJwsTool? signer)
{
+ _log.Verbose("Loading ACME account");
AccountDetails? account = null;
if (File.Exists(AccountPath))
{
@@ -180,10 +185,11 @@ namespace PKISharp.WACS.Clients.Acme }
else
{
- var contacts = await GetContacts();
+ _log.Verbose("No account found at {path}, creating new one", AccountPath);
try
{
var (_, filename, content) = await client.GetTermsOfServiceAsync();
+ _log.Verbose("Terms of service downloaded");
if (!string.IsNullOrEmpty(filename))
{
if (!await AcceptTos(filename, content))
@@ -196,32 +202,81 @@ namespace PKISharp.WACS.Clients.Acme {
_log.Error(ex, "Error getting terms of service");
}
+ var contacts = default(string[]);
+ var externalAccount = default(ExternalAccountBinding);
+
+ var kid = _accountArguments.EabKeyIdentifier;
+ var key = _accountArguments.EabKey;
+ var alg = _accountArguments.EabAlgorithm ?? "HS256";
+ var eabFlow = client.Directory?.Meta?.ExternalAccountRequired == "true";
+ if (eabFlow)
+ {
+ _input.CreateSpace();
+ _input.Show(null, "This ACME endpoint requires an external account. You will " +
+ "need to provide a key identifier and a key to proceed. Please refer to the " +
+ "providers instructions on how to obtain these.");
+ }
+ else if (!string.IsNullOrWhiteSpace(kid))
+ {
+ eabFlow = true;
+ _input.CreateSpace();
+ _input.Show(null, "You have provided external account binding key, but the server" +
+ "claims that is not required. We will attempt to register using this key anyway.");
+ }
+ if (eabFlow)
+ {
+ if (string.IsNullOrWhiteSpace(kid))
+ {
+ kid = await _input.RequestString("Key identifier");
+ }
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ key = await _input.ReadPassword("Key (base64url encoded)");
+ }
+ externalAccount = new ExternalAccountBinding(
+ alg,
+ JsonConvert.SerializeObject(client.Signer.ExportJwk(), Formatting.None),
+ kid,
+ key ?? "",
+ client.Directory?.NewAccount ?? "");
+ }
+ else
+ {
+ contacts = await GetContacts();
+ }
try
{
- account = await client.CreateAccountAsync(contacts, termsOfServiceAgreed: true);
+ account = await client.CreateAccountAsync(
+ contacts,
+ termsOfServiceAgreed: true,
+ externalAccountBinding: externalAccount?.Payload() ?? null);
}
catch (Exception ex)
{
_log.Error(ex, "Error creating account");
}
- try
+ if (account != null)
{
- _log.Debug("Saving account");
- var accountKey = new AccountSigner
+ try
{
- 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;
+ _log.Debug("Saving account");
+ var accountKey = new AccountSigner
+ {
+ KeyType = client.Signer.JwsAlg,
+ KeyExport = client.Signer.Export(),
+ };
+ AccountSigner = accountKey;
+ await File.WriteAllTextAsync(AccountPath, JsonConvert.SerializeObject(account));
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error saving account");
+ account = null;
+ }
}
+
}
return account;
}
@@ -236,9 +291,10 @@ namespace PKISharp.WACS.Clients.Acme private async Task<bool> AcceptTos(string filename, byte[] content)
{
var tosPath = Path.Combine(_settings.Client.ConfigurationPath, filename);
- File.WriteAllBytes(tosPath, content);
+ _log.Verbose("Writing terms of service to {path}", tosPath);
+ await File.WriteAllBytesAsync(tosPath, content);
_input.Show($"Terms of service", tosPath);
- if (_arguments.MainArguments.AcceptTos)
+ if (_arguments.GetArguments<AccountArguments>().AcceptTos)
{
return true;
}
@@ -297,7 +353,7 @@ namespace PKISharp.WACS.Clients.Acme /// <returns></returns>
private async Task<string[]> GetContacts()
{
- var email = _arguments.MainArguments.EmailAddress;
+ var email = _accountArguments.EmailAddress;
if (string.IsNullOrWhiteSpace(email))
{
email = await _input.RequestString("Enter email(s) for notifications about problems and abuse (comma seperated)");
diff --git a/src/main.lib/Clients/Acme/ExternalAccountBinding.cs b/src/main.lib/Clients/Acme/ExternalAccountBinding.cs new file mode 100644 index 0000000..999d4e9 --- /dev/null +++ b/src/main.lib/Clients/Acme/ExternalAccountBinding.cs @@ -0,0 +1,61 @@ +using ACMESharp.Crypto;
+using ACMESharp.Crypto.JOSE;
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace PKISharp.WACS.Clients.Acme
+{
+ class ExternalAccountBinding
+ {
+ public string AccountKey { get; set; }
+ public string Algorithm { get; set; }
+ public string Key { get; set; }
+ public string KeyIdentifier { get; set; }
+ public string Url { get; set; }
+
+ public ExternalAccountBinding(string algorithm, string accountKey, string keyIdentifier, string key, string url)
+ {
+ Algorithm = algorithm;
+ AccountKey = accountKey;
+ KeyIdentifier = keyIdentifier;
+ Url = url;
+ Key = key;
+ }
+
+ public JwsSignedPayload Payload()
+ {
+ var protectedHeader = new Dictionary<string, object>
+ {
+ ["alg"] = Algorithm,
+ ["kid"] = KeyIdentifier,
+ ["url"] = Url
+ };
+ return JwsHelper.SignFlatJsonAsObject(Sign, AccountKey, protectedHeader, null);
+ }
+
+ public byte[] Sign(byte[] input)
+ {
+ var keyBytes = CryptoHelper.Base64.UrlDecode(Key);
+ switch (Algorithm)
+ {
+ case "HS256":
+ {
+ using var hmac = new HMACSHA256(keyBytes);
+ return hmac.ComputeHash(input);
+ }
+ case "HS384":
+ {
+ using var hmac = new HMACSHA384(keyBytes);
+ return hmac.ComputeHash(input);
+ }
+ case "HS512":
+ {
+ using var hmac = new HMACSHA512(keyBytes);
+ return hmac.ComputeHash(input);
+ }
+ }
+ throw new InvalidOperationException();
+ }
+ }
+}
diff --git a/src/main.lib/Clients/AcmeDnsClient.cs b/src/main.lib/Clients/AcmeDnsClient.cs index fb6cd0d..03cf733 100644 --- a/src/main.lib/Clients/AcmeDnsClient.cs +++ b/src/main.lib/Clients/AcmeDnsClient.cs @@ -81,7 +81,7 @@ namespace PKISharp.WACS.Clients } if (await VerifyRegistration(domain, newReg.Fulldomain, interactive)) { - File.WriteAllText(FileForDomain(domain), JsonConvert.SerializeObject(newReg)); + await File.WriteAllTextAsync(FileForDomain(domain), JsonConvert.SerializeObject(newReg)); return true; } } @@ -112,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("Press 'Y' or <Enter> to retry, or 'N' to skip this step.", true)) { _log.Warning("Verification of acme-dns configuration skipped."); return true; @@ -132,36 +132,44 @@ namespace PKISharp.WACS.Clients /// <param name="cname"></param> /// <returns></returns> private async Task<bool> VerifyCname(string domain, string expected, int 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(", ", result.Select(x => x.IpAddress))); + {
+ try
+ {
+ 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(", ", result.Select(x => x.IpAddress))); - // Parallel queries - var answers = await Task.WhenAll(result.Select(client => client.GetCname($"_acme-challenge.{domain}"))); + // Parallel queries + var answers = await Task.WhenAll(result.Select(client => client.GetCname($"_acme-challenge.{domain}"))); - // Loop through results - for (var i = 0; i < result.Count(); i++) - { - var currentClient = result[i]; - var currentResult = answers[i]; - if (string.Equals(expected, currentResult, StringComparison.CurrentCultureIgnoreCase)) - { - _log.Verbose("Verification of CNAME record successful at server {server}", currentClient.IpAddress); - } - else + // Loop through results + for (var i = 0; i < result.Count(); i++) { - _log.Warning("Verification failed, {domain} found value {found} but expected {expected} at server {server}", - $"_acme-challenge.{domain}", - currentResult ?? "(null)", - expected, - currentClient.IpAddress); - return false; + var currentClient = result[i]; + var currentResult = answers[i]; + if (string.Equals(expected, currentResult, StringComparison.CurrentCultureIgnoreCase)) + { + _log.Verbose("Verification of CNAME record successful at server {server}", currentClient.IpAddress); + } + else + { + _log.Warning("Verification failed, {domain} found value {found} but expected {expected} at server {server}", + $"_acme-challenge.{domain}", + currentResult ?? "(null)", + expected, + currentClient.IpAddress); + return false; + } } + _log.Information("Verification of acme-dns configuration succesful."); + return true;
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Unable to verify acme-dns configuration.");
+ return false;
} - _log.Information("Verification of acme-dns configuration succesful."); - return true; } private string FileForDomain(string domain) => Path.Combine(_dnsConfigPath, $"{domain.CleanPath()}.json"); diff --git a/src/main.lib/Clients/DNS/LookupClientProvider.cs b/src/main.lib/Clients/DNS/LookupClientProvider.cs index 806fff7..6c3d012 100644 --- a/src/main.lib/Clients/DNS/LookupClientProvider.cs +++ b/src/main.lib/Clients/DNS/LookupClientProvider.cs @@ -2,6 +2,7 @@ using PKISharp.WACS.Services; using Serilog.Context; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; @@ -12,8 +13,9 @@ namespace PKISharp.WACS.Clients.DNS public class LookupClientProvider { private readonly List<IPAddress> _defaultNs; - private readonly Dictionary<string, IEnumerable<IPAddress>> _authoritativeNs; - private readonly Dictionary<string, LookupClientWrapper> _lookupClients; + private readonly ConcurrentDictionary<string, IEnumerable<IPAddress>> _authoritativeNs; + private readonly ConcurrentDictionary<string, string?> _cnames; + private readonly ConcurrentDictionary<IPAddress, LookupClientWrapper> _lookupClients; private readonly ILogService _log; private readonly ISettingsService _settings; @@ -27,8 +29,9 @@ namespace PKISharp.WACS.Clients.DNS _log = logService; _settings = settings; _domainParser = domainParser; - _authoritativeNs = new Dictionary<string, IEnumerable<IPAddress>>(); - _lookupClients = new Dictionary<string, LookupClientWrapper>(); + _authoritativeNs = new ConcurrentDictionary<string, IEnumerable<IPAddress>>(); + _cnames = new ConcurrentDictionary<string, string?>(); + _lookupClients = new ConcurrentDictionary<IPAddress, LookupClientWrapper>(); _defaultNs = ParseDefaultClients(); } @@ -88,20 +91,7 @@ namespace PKISharp.WACS.Clients.DNS /// </summary> /// <param name="ip"></param> /// <returns></returns> - private LookupClientWrapper Produce(IPAddress ip) - { - var key = ip.ToString(); - if (!_lookupClients.ContainsKey(key)) - { - _lookupClients.Add( - key, - new LookupClientWrapper( - _log, - ip.Equals(new IPAddress(0)) ? null : ip, - this)); - } - return _lookupClients[key]; - } + private LookupClientWrapper Produce(IPAddress ip) => _lookupClients.GetOrAdd(ip, (ip) => new LookupClientWrapper(_log, ip.Equals(new IPAddress(0)) ? null : ip, this)); /// <summary> /// Get clients for all default DNS servers @@ -142,78 +132,80 @@ namespace PKISharp.WACS.Clients.DNS 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 { - try + // Example: _acme-challenge.sub.example.co.uk + domainName = domainName.TrimEnd('.'); + + // 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 ask: + // 1. example + // 1. sub + // 2. _acme-challenge + var remainingParts = domainName.Substring(0, domainName.LastIndexOf(rootDomain)) + .Trim('.').Split('.') + .Where(x => !string.IsNullOrEmpty(x)); + remainingParts = remainingParts.Reverse(); + + var digDeeper = true; + IEnumerable<IPAddress>? ipSet = null; + do { - // Example: _acme-challenge.sub.example.co.uk - domainName = domainName.TrimEnd('.'); - - // 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 ask: - // 1. example - // 1. sub - // 2. _acme-challenge - var remainingParts = domainName.Substring(0, domainName.LastIndexOf(rootDomain)) - .Trim('.').Split('.') - .Where(x => !string.IsNullOrEmpty(x)); - remainingParts = remainingParts.Reverse(); - - var digDeeper = true; - IEnumerable<IPAddress>? ipSet = null; - do + // Partial result cachign + if (!_authoritativeNs.ContainsKey(testZone)) { - // Partial result cachign - if (!_authoritativeNs.ContainsKey(testZone)) + _log.Verbose("Querying server {server} about {part}", client.IpAddress, testZone); + using (LogContext.PushProperty("Domain", testZone)) { - _log.Verbose("Querying server {server} about {part}", client.IpAddress, testZone); - using (LogContext.PushProperty("Domain", testZone)) - { - var tempResult = await client.GetNameServers(testZone, round); - _authoritativeNs.Add(testZone, tempResult?.ToList() ?? ipSet ?? _defaultNs); - } + var tempResult = await client.GetNameServers(testZone, round); + _authoritativeNs.TryAdd(testZone, tempResult?.ToList() ?? ipSet ?? _defaultNs); } - ipSet = _authoritativeNs[testZone]; - client = Produce(ipSet.OrderBy(x => Guid.NewGuid()).First()); + } + ipSet = _authoritativeNs[testZone]; + client = Produce(ipSet.OrderBy(x => Guid.NewGuid()).First()); - // CNAME only valid for full domain. Subdomains may be - // regular records again - if (followCnames && testZone == key) + // CNAME only valid for full domain. Subdomains may be + // regular records again + if (followCnames && testZone == key) + { + var cname = default(string?); + if (!_cnames.ContainsKey(key)) { - var cname = await client.GetCname(testZone); - if (cname != null) - { - return await GetAuthority(cname, round, true, Produce(key, from)); - } + _cnames.TryAdd(key, await client.GetCname(testZone)); + } + cname = _cnames[key]; + if (cname != null) + { + return await GetAuthority(cname, round, true, Produce(key, from)); } + } - if (remainingParts.Any()) - { - testZone = $"{remainingParts.First()}.{testZone}"; - remainingParts = remainingParts.Skip(1).ToArray(); - } - else - { - digDeeper = false; - } + if (remainingParts.Any()) + { + testZone = $"{remainingParts.First()}.{testZone}"; + remainingParts = remainingParts.Skip(1).ToArray(); } - while (digDeeper); - - if (ipSet == null) + else { - throw new Exception("No results"); + digDeeper = false; } } - catch (Exception ex) + while (digDeeper); + + if (ipSet == null) { - _log.Warning("Unable to find or contact authoritative name servers for {domainName}: {message}", domainName, ex.Message); - _authoritativeNs.Add(key, _defaultNs); + throw new Exception("No results"); } } + catch (Exception ex) + { + _log.Warning("Unable to find or contact authoritative name servers for {domainName}: {message}", domainName, ex.Message); + _authoritativeNs.TryAdd(key, _defaultNs); + } return Produce(key, from); } diff --git a/src/main.lib/Clients/EmailClient.cs b/src/main.lib/Clients/EmailClient.cs index 4f77115..0f7f0a0 100644 --- a/src/main.lib/Clients/EmailClient.cs +++ b/src/main.lib/Clients/EmailClient.cs @@ -122,8 +122,8 @@ namespace PKISharp.WACS.Clients bodyBuilder.HtmlBody = content + $"<p>Sent by win-acme version {_version} from {_computerName}</p>"; message.Body = bodyBuilder.ToMessageBody(); await client.SendAsync(message); - await client.DisconnectAsync(true); - } + }
+ await client.DisconnectAsync(true); } catch (Exception ex) { diff --git a/src/main.lib/Configuration/AccountArguments.cs b/src/main.lib/Configuration/AccountArguments.cs new file mode 100644 index 0000000..d1f6020 --- /dev/null +++ b/src/main.lib/Configuration/AccountArguments.cs @@ -0,0 +1,11 @@ +namespace PKISharp.WACS.Configuration +{ + public class AccountArguments + { + public bool AcceptTos { get; set; } + public string? EmailAddress { get; set; } + public string? EabKeyIdentifier { get; set; } + public string? EabKey { get; set; } + public string? EabAlgorithm { get; set; } + } +}
\ No newline at end of file diff --git a/src/main.lib/Configuration/AccountArgumentsProvider.cs b/src/main.lib/Configuration/AccountArgumentsProvider.cs new file mode 100644 index 0000000..883e7ba --- /dev/null +++ b/src/main.lib/Configuration/AccountArgumentsProvider.cs @@ -0,0 +1,46 @@ +using Fclp; + +namespace PKISharp.WACS.Configuration +{ + internal class AccountArgumentsProvider : BaseArgumentsProvider<AccountArguments> + { + public override string Name => "Account"; + public override string Group => ""; + public override string Condition => ""; + + protected override bool IsActive(AccountArguments current) + { + return + current.AcceptTos || + !string.IsNullOrEmpty(current.EabAlgorithm) || + !string.IsNullOrEmpty(current.EabKey) || + !string.IsNullOrEmpty(current.EabKeyIdentifier) || + !string.IsNullOrEmpty(current.EmailAddress); + } + + public override void Configure(FluentCommandLineParser<AccountArguments> parser) + { + // Acme account registration + parser.Setup(o => o.AcceptTos) + .As("accepttos") + .WithDescription("Accept the ACME terms of service."); + + parser.Setup(o => o.EmailAddress) + .As("emailaddress") + .WithDescription("Email address to use by ACME for renewal fail notices."); + + // External account binding + parser.Setup(o => o.EabKeyIdentifier) + .As("eab-key-identifier") + .WithDescription("Key identifier to use for external account binding."); + + parser.Setup(o => o.EabKey) + .As("eab-key") + .WithDescription("Key to use for external account binding. Must be base64url encoded."); + + parser.Setup(o => o.EabAlgorithm) + .As("eab-algorithm") + .WithDescription("Algorithm to use for external account binding. Valid values are HS256 (default), HS384, and HS512."); + } + } +}
\ No newline at end of file diff --git a/src/main.lib/Configuration/MainArguments.cs b/src/main.lib/Configuration/MainArguments.cs index e446d8a..b4ba61c 100644 --- a/src/main.lib/Configuration/MainArguments.cs +++ b/src/main.lib/Configuration/MainArguments.cs @@ -37,8 +37,6 @@ namespace PKISharp.WACS.Configuration public bool NoTaskScheduler { get; set; } public bool UseDefaultTaskUser { get; set; } - public bool AcceptTos { get; set; } - public string? EmailAddress { get; set; } public bool Encrypt { get; set; } } }
\ No newline at end of file diff --git a/src/main.lib/Configuration/MainArgumentsProvider.cs b/src/main.lib/Configuration/MainArgumentsProvider.cs index 6fd17fa..7597206 100644 --- a/src/main.lib/Configuration/MainArgumentsProvider.cs +++ b/src/main.lib/Configuration/MainArgumentsProvider.cs @@ -134,19 +134,9 @@ namespace PKISharp.WACS.Configuration .As("usedefaulttaskuser") .WithDescription("(Obsolete) Avoid the question about specifying the task scheduler user, as such defaulting to the SYSTEM account."); - // Acme account registration - - parser.Setup(o => o.AcceptTos) - .As("accepttos") - .WithDescription("Accept the ACME terms of service."); - - parser.Setup(o => o.EmailAddress) - .As("emailaddress") - .WithDescription("Email address to use by ACME for renewal fail notices."); parser.Setup(o => o.Encrypt) .As("encrypt") .WithDescription("Rewrites all renewal information using current EncryptConfig setting"); - } } }
\ No newline at end of file diff --git a/src/main.lib/Plugins/Base/OptionsFactories/Null/NullInstallationOptionsFactory.cs b/src/main.lib/Plugins/Base/OptionsFactories/Null/NullInstallationOptionsFactory.cs index 7adf7e2..45b6a3f 100644 --- a/src/main.lib/Plugins/Base/OptionsFactories/Null/NullInstallationOptionsFactory.cs +++ b/src/main.lib/Plugins/Base/OptionsFactories/Null/NullInstallationOptionsFactory.cs @@ -23,7 +23,7 @@ namespace PKISharp.WACS.Plugins.Base.Factories.Null (bool, string?) IPluginOptionsFactory.Disabled => (false, null); string IPluginOptionsFactory.Name => new NullInstallationOptions().Name; string IPluginOptionsFactory.Description => new NullInstallationOptions().Description; - bool IPluginOptionsFactory.Match(string name) => string.Equals(name, new NullInstallationOptions().Name, StringComparison.CurrentCultureIgnoreCase); + bool IPluginOptionsFactory.Match(string name) => string.Equals(name, new NullInstallationOptions().Name, StringComparison.InvariantCultureIgnoreCase); } [Plugin("aecc502c-5f75-43d2-b578-f95d50c79ea1")] diff --git a/src/main.lib/Plugins/Base/OptionsFactories/Null/NullStoreOptionsFactory.cs b/src/main.lib/Plugins/Base/OptionsFactories/Null/NullStoreOptionsFactory.cs index e98cd78..b435bda 100644 --- a/src/main.lib/Plugins/Base/OptionsFactories/Null/NullStoreOptionsFactory.cs +++ b/src/main.lib/Plugins/Base/OptionsFactories/Null/NullStoreOptionsFactory.cs @@ -20,7 +20,7 @@ namespace PKISharp.WACS.Plugins.Base.Factories.Null (bool, string?) IPluginOptionsFactory.Disabled => (false, null); string IPluginOptionsFactory.Name => NullStoreOptions.PluginName; string IPluginOptionsFactory.Description => new NullStoreOptions().Description; - bool IPluginOptionsFactory.Match(string name) => string.Equals(name, new NullInstallationOptions().Name, StringComparison.CurrentCultureIgnoreCase); + bool IPluginOptionsFactory.Match(string name) => string.Equals(name, new NullInstallationOptions().Name, StringComparison.InvariantCultureIgnoreCase); int IPluginOptionsFactory.Order => int.MaxValue; } diff --git a/src/main.lib/Plugins/Base/OptionsFactories/PluginOptionsFactory.cs b/src/main.lib/Plugins/Base/OptionsFactories/PluginOptionsFactory.cs index bc42d8a..e65f45e 100644 --- a/src/main.lib/Plugins/Base/OptionsFactories/PluginOptionsFactory.cs +++ b/src/main.lib/Plugins/Base/OptionsFactories/PluginOptionsFactory.cs @@ -31,7 +31,7 @@ namespace PKISharp.WACS.Plugins.Base.Factories /// </summary> /// <param name="name"></param> /// <returns></returns> - public virtual bool Match(string name) => string.Equals(name, _name, StringComparison.CurrentCultureIgnoreCase); + public virtual bool Match(string name) => string.Equals(name, _name, StringComparison.InvariantCultureIgnoreCase); Type IPluginOptionsFactory.OptionsType => typeof(TOptions); Type IPluginOptionsFactory.InstanceType => typeof(TPlugin); diff --git a/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs b/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs index e8fd230..dba5ce4 100644 --- a/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs +++ b/src/main.lib/Plugins/Resolvers/UnattendedResolver.cs @@ -99,7 +99,7 @@ namespace PKISharp.WACS.Plugins.Resolvers _log.Error("{n} plugin {x} not available: {m}. " + changeInstructions, char.ToUpper(className[0]) + className.Substring(1), defaultOption.plugin?.Name ?? "Unknown", - defaultTypeDisabled.Item2); + defaultTypeDisabled.Item2?.TrimEnd('.')); return nullResult; } @@ -234,7 +234,7 @@ namespace PKISharp.WACS.Plugins.Resolvers #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. nullResult: default, #pragma warning restore CS8625 - className: "store"); + className: "installation"); } } } diff --git a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs index a9e3a48..e6ca806 100644 --- a/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs +++ b/src/main.lib/Plugins/StorePlugins/CentralSsl/CentralSsl.cs @@ -62,7 +62,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins private string PathForIdentifier(string identifier) => Path.Combine(_path, $"{identifier.Replace("*", "_")}.pfx"); - public Task Save(CertificateInfo input) + public async Task Save(CertificateInfo input) { _log.Information("Copying certificate to the CentralSsl store"); foreach (var identifier in input.SanNames) @@ -76,20 +76,20 @@ namespace PKISharp.WACS.Plugins.StorePlugins input.Certificate }; collection.AddRange(input.Chain.ToArray()); - File.WriteAllBytes(dest, collection.Export(X509ContentType.Pfx, _password)); + await File.WriteAllBytesAsync(dest, collection.Export(X509ContentType.Pfx, _password)); } catch (Exception ex) { _log.Error(ex, "Error copying certificate to CentralSsl store"); } - } - input.StoreInfo.Add(GetType(), + }
+ input.StoreInfo.TryAdd(
+ GetType(), new StoreInfo() { Name = CentralSslOptions.PluginName, Path = _path }); - return Task.CompletedTask; } public Task Delete(CertificateInfo input) diff --git a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs index 6d2be30..1cb3e41 100644 --- a/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs +++ b/src/main.lib/Plugins/StorePlugins/CertificateStore/CertificateStore.cs @@ -1,345 +1,345 @@ -using PKISharp.WACS.Clients.IIS; -using PKISharp.WACS.DomainObjects; -using PKISharp.WACS.Plugins.Interfaces; -using PKISharp.WACS.Services; -using System; -using System.Collections.Generic; -using System.IO; -using static System.IO.FileSystemAclExtensions; -using System.Linq; -using System.Security.AccessControl; -using System.Security.Cryptography.X509Certificates; -using System.Security.Principal; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Plugins.StorePlugins -{ - internal class CertificateStore : IStorePlugin, IDisposable - { - private readonly ILogService _log; - private readonly ISettingsService _settings; - private const string _defaultStoreName = nameof(StoreName.My); - private string? _storeName; - private readonly X509Store _store; - private readonly IIISClient _iisClient; - private readonly CertificateStoreOptions _options; - private readonly IUserRoleService _userRoleService; - private readonly FindPrivateKey _keyFinder; - - public CertificateStore( - ILogService log, IIISClient iisClient, - ISettingsService settings, IUserRoleService userRoleService, - FindPrivateKey keyFinder, CertificateStoreOptions options) - { - _log = log; - _iisClient = iisClient; - _options = options; - _settings = settings; - _userRoleService = userRoleService; - _keyFinder = keyFinder; - ParseCertificateStore(); - _store = new X509Store(_storeName, StoreLocation.LocalMachine); - } - - private void ParseCertificateStore() - { - try - { - // First priority: specified in the parameters - _storeName = _options.StoreName; - - // Second priority: specified in settings.json - if (string.IsNullOrEmpty(_storeName)) - { - _storeName = _settings.Store.CertificateStore?.DefaultStore; - } - if (string.IsNullOrEmpty(_storeName)) - { - _storeName = _settings.Store.DefaultCertificateStore; - } - - // Third priority: defaults - if (string.IsNullOrEmpty(_storeName)) - { - // Default store should be WebHosting on IIS8+, and My (Personal) for IIS7.x - _storeName = _iisClient.Version.Major < 8 ? nameof(StoreName.My) : "WebHosting"; - } - - // Rewrite - if (string.Equals(_storeName, "Personal", StringComparison.InvariantCultureIgnoreCase)) - { - // Users trying to use the "My" store might have set "Personal" in their - // config files, because that's what the store is called in mmc - _storeName = nameof(StoreName.My); - } - - _log.Debug("Certificate store: {_certificateStore}", _storeName); - } - catch (Exception ex) - { - _log.Warning("Error reading CertificateStore from config, defaulting to {_certificateStore} Error: {@ex}", _defaultStoreName, ex); - } - } - - public Task Save(CertificateInfo input) - { - var existing = FindByThumbprint(input.Certificate.Thumbprint); - if (existing != null) - { - _log.Warning("Certificate with thumbprint {thumbprint} is already in the store", input.Certificate.Thumbprint); - } - else - { - var certificate = input.Certificate; - if (!_settings.Security.PrivateKeyExportable && input.CacheFile != null) - { - certificate = new X509Certificate2( - input.CacheFile.FullName, - input.CacheFilePassword, - X509KeyStorageFlags.MachineKeySet | - X509KeyStorageFlags.PersistKeySet); - } - _log.Information("Installing certificate in the certificate store"); - InstallCertificate(certificate); - if (_options.AclFullControl != null) - { - SetAcl(certificate, _options.AclFullControl); - } - InstallCertificateChain(input.Chain); - - } - input.StoreInfo.Add( - GetType(), - new StoreInfo() - { - Name = CertificateStoreOptions.PluginName, - Path = _store.Name - }); - return Task.CompletedTask; - } - - private void SetAcl(X509Certificate2 cert, List<string> fullControl) - { - try - { - var file = _keyFinder.Find(cert); - if (file != null) - { - _log.Verbose("Private key found at {dir}", file.FullName); - var fs = new FileSecurity(file.FullName, AccessControlSections.All); - foreach (var account in fullControl) - { - try - { - var principal = new NTAccount(account); - fs.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, AccessControlType.Allow)); - _log.Information("Add full control rights for {account}", account); - } - catch - { - _log.Warning("Unable to set full control rights for {account}", account); - } - } - file.SetAccessControl(fs); - } - } - catch (Exception ex) - { - _log.Error(ex, "Unable to set requested ACL on private key"); - } - } - - public Task Delete(CertificateInfo input) - { - _log.Information("Uninstalling certificate from the certificate store"); - UninstallCertificate(input.Certificate.Thumbprint); - return Task.CompletedTask; - } - - public CertificateInfo? FindByThumbprint(string thumbprint) => ToInfo(GetCertificate(CertificateService.ThumbprintFilter(thumbprint))); - - private CertificateInfo? ToInfo(X509Certificate2? cert) - { - if (cert != null) - { - var ret = new CertificateInfo(cert); - ret.StoreInfo.Add( - GetType(), - new StoreInfo() - { - Path = _store.Name - }); - return ret; - } - else - { - return null; - } - } - - private void InstallCertificate(X509Certificate2 certificate) - { - try - { - _store.Open(OpenFlags.ReadWrite); - _log.Debug("Opened certificate store {Name}", _store.Name); - } - catch - { - _log.Error("Error encountered while opening certificate store {name}", _store.Name); - throw; - } - - try - { - _log.Information(LogType.All, "Adding certificate {FriendlyName} to store {name}", certificate.FriendlyName, _store.Name); - _log.Verbose("{sub} - {iss} ({thumb})", certificate.Subject, certificate.Issuer, certificate.Thumbprint); - _store.Add(certificate); - } - catch - { - _log.Error("Error saving certificate"); - throw; - } - _log.Debug("Closing certificate store"); - _store.Close(); - } - - private void InstallCertificateChain(List<X509Certificate2> chain) - { - X509Store imStore; - try - { - imStore = new X509Store(StoreName.CertificateAuthority, StoreLocation.LocalMachine); - imStore.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); - if (!imStore.IsOpen) - { - _log.Verbose("Unable to open intermediate certificate authority store"); - imStore = new X509Store(_store.Name, StoreLocation.LocalMachine); - imStore.Open(OpenFlags.ReadWrite); - } - } - catch - { - _log.Warning("Error encountered while opening intermediate certificate store"); - return; - } - - foreach (var cert in chain) - { - try - { - _log.Verbose("{sub} - {iss} ({thumb}) to store {store}", cert.Subject, cert.Issuer, cert.Thumbprint, imStore.Name); - imStore.Add(cert); - } - catch (Exception ex) - { - _log.Warning("Error saving certificate to store {store}: {message}", imStore.Name, ex.Message); - } - } - - _log.Debug("Closing store {store}", imStore.Name); - imStore.Close(); - } - - private void UninstallCertificate(string thumbprint) - { - try - { - _store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); - } - catch (Exception ex) - { - _log.Error(ex, "Error encountered while opening certificate store"); - throw; - } - - _log.Debug("Opened certificate store {Name}", _store.Name); - try - { - var col = _store.Certificates; - foreach (var cert in col) - { - if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.InvariantCultureIgnoreCase)) - { - _log.Information(LogType.All, "Removing certificate {cert} from store {name}", cert.FriendlyName, _store.Name); - _store.Remove(cert); - } - } - _log.Debug("Closing certificate store"); - } - catch (Exception ex) - { - _log.Error(ex, "Error removing certificate"); - throw; - } - _store.Close(); - } - - private X509Certificate2? GetCertificate(Func<X509Certificate2, bool> filter) - { - var possibles = new List<X509Certificate2>(); - try - { - _store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); - } - catch (Exception ex) - { - _log.Error(ex, "Error encountered while opening certificate store"); - return null; - } - try - { - var col = _store.Certificates; - foreach (var cert in col) - { - if (filter(cert)) - { - possibles.Add(cert); - } - } - } - catch (Exception ex) - { - _log.Error(ex, "Error finding certificate in certificate store"); - return null; - } - _store.Close(); - return possibles.OrderByDescending(x => x.NotBefore).FirstOrDefault(); - } - - (bool, string?) IPlugin.Disabled => Disabled(_userRoleService); - - internal static (bool, string?) Disabled(IUserRoleService userRoleService) - { - if (userRoleService.IsAdmin) - { - return (false, null); - } - else - { - return (true, "Run as administrator to allow certificate store access."); - } - } - - #region IDisposable - - private bool disposedValue = false; // To detect redundant calls - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - _store.Dispose(); - } - disposedValue = true; - } - } - - public void Dispose() => Dispose(true); - - #endregion - } +using PKISharp.WACS.Clients.IIS;
+using PKISharp.WACS.DomainObjects;
+using PKISharp.WACS.Plugins.Interfaces;
+using PKISharp.WACS.Services;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using static System.IO.FileSystemAclExtensions;
+using System.Linq;
+using System.Security.AccessControl;
+using System.Security.Cryptography.X509Certificates;
+using System.Security.Principal;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Plugins.StorePlugins
+{
+ internal class CertificateStore : IStorePlugin, IDisposable
+ {
+ private readonly ILogService _log;
+ private readonly ISettingsService _settings;
+ private const string _defaultStoreName = nameof(StoreName.My);
+ private string? _storeName;
+ private readonly X509Store _store;
+ private readonly IIISClient _iisClient;
+ private readonly CertificateStoreOptions _options;
+ private readonly IUserRoleService _userRoleService;
+ private readonly FindPrivateKey _keyFinder;
+
+ public CertificateStore(
+ ILogService log, IIISClient iisClient,
+ ISettingsService settings, IUserRoleService userRoleService,
+ FindPrivateKey keyFinder, CertificateStoreOptions options)
+ {
+ _log = log;
+ _iisClient = iisClient;
+ _options = options;
+ _settings = settings;
+ _userRoleService = userRoleService;
+ _keyFinder = keyFinder;
+ ParseCertificateStore();
+ _store = new X509Store(_storeName, StoreLocation.LocalMachine);
+ }
+
+ private void ParseCertificateStore()
+ {
+ try
+ {
+ // First priority: specified in the parameters
+ _storeName = _options.StoreName;
+
+ // Second priority: specified in settings.json
+ if (string.IsNullOrEmpty(_storeName))
+ {
+ _storeName = _settings.Store.CertificateStore?.DefaultStore;
+ }
+ if (string.IsNullOrEmpty(_storeName))
+ {
+ _storeName = _settings.Store.DefaultCertificateStore;
+ }
+
+ // Third priority: defaults
+ if (string.IsNullOrEmpty(_storeName))
+ {
+ // Default store should be WebHosting on IIS8+, and My (Personal) for IIS7.x
+ _storeName = _iisClient.Version.Major < 8 ? nameof(StoreName.My) : "WebHosting";
+ }
+
+ // Rewrite
+ if (string.Equals(_storeName, "Personal", StringComparison.InvariantCultureIgnoreCase))
+ {
+ // Users trying to use the "My" store might have set "Personal" in their
+ // config files, because that's what the store is called in mmc
+ _storeName = nameof(StoreName.My);
+ }
+
+ _log.Debug("Certificate store: {_certificateStore}", _storeName);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Error reading CertificateStore from config, defaulting to {_certificateStore} Error: {@ex}", _defaultStoreName, ex);
+ }
+ }
+
+ public Task Save(CertificateInfo input)
+ {
+ var existing = FindByThumbprint(input.Certificate.Thumbprint);
+ if (existing != null)
+ {
+ _log.Warning("Certificate with thumbprint {thumbprint} is already in the store", input.Certificate.Thumbprint);
+ }
+ else
+ {
+ var certificate = input.Certificate;
+ if (!_settings.Security.PrivateKeyExportable && input.CacheFile != null)
+ {
+ certificate = new X509Certificate2(
+ input.CacheFile.FullName,
+ input.CacheFilePassword,
+ X509KeyStorageFlags.MachineKeySet |
+ X509KeyStorageFlags.PersistKeySet);
+ }
+ _log.Information("Installing certificate in the certificate store");
+ InstallCertificate(certificate);
+ if (_options.AclFullControl != null)
+ {
+ SetAcl(certificate, _options.AclFullControl);
+ }
+ InstallCertificateChain(input.Chain);
+
+ }
+ input.StoreInfo.TryAdd(
+ GetType(),
+ new StoreInfo()
+ {
+ Name = CertificateStoreOptions.PluginName,
+ Path = _store.Name
+ });
+ return Task.CompletedTask;
+ }
+
+ private void SetAcl(X509Certificate2 cert, List<string> fullControl)
+ {
+ try
+ {
+ var file = _keyFinder.Find(cert);
+ if (file != null)
+ {
+ _log.Verbose("Private key found at {dir}", file.FullName);
+ var fs = new FileSecurity(file.FullName, AccessControlSections.All);
+ foreach (var account in fullControl)
+ {
+ try
+ {
+ var principal = new NTAccount(account);
+ fs.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, AccessControlType.Allow));
+ _log.Information("Add full control rights for {account}", account);
+ }
+ catch
+ {
+ _log.Warning("Unable to set full control rights for {account}", account);
+ }
+ }
+ file.SetAccessControl(fs);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Unable to set requested ACL on private key");
+ }
+ }
+
+ public Task Delete(CertificateInfo input)
+ {
+ _log.Information("Uninstalling certificate from the certificate store");
+ UninstallCertificate(input.Certificate.Thumbprint);
+ return Task.CompletedTask;
+ }
+
+ public CertificateInfo? FindByThumbprint(string thumbprint) => ToInfo(GetCertificate(CertificateService.ThumbprintFilter(thumbprint)));
+
+ private CertificateInfo? ToInfo(X509Certificate2? cert)
+ {
+ if (cert != null)
+ {
+ var ret = new CertificateInfo(cert);
+ ret.StoreInfo.Add(
+ GetType(),
+ new StoreInfo()
+ {
+ Path = _store.Name
+ });
+ return ret;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ private void InstallCertificate(X509Certificate2 certificate)
+ {
+ try
+ {
+ _store.Open(OpenFlags.ReadWrite);
+ _log.Debug("Opened certificate store {Name}", _store.Name);
+ }
+ catch
+ {
+ _log.Error("Error encountered while opening certificate store {name}", _store.Name);
+ throw;
+ }
+
+ try
+ {
+ _log.Information(LogType.All, "Adding certificate {FriendlyName} to store {name}", certificate.FriendlyName, _store.Name);
+ _log.Verbose("{sub} - {iss} ({thumb})", certificate.Subject, certificate.Issuer, certificate.Thumbprint);
+ _store.Add(certificate);
+ }
+ catch
+ {
+ _log.Error("Error saving certificate");
+ throw;
+ }
+ _log.Debug("Closing certificate store");
+ _store.Close();
+ }
+
+ private void InstallCertificateChain(List<X509Certificate2> chain)
+ {
+ X509Store imStore;
+ try
+ {
+ imStore = new X509Store(StoreName.CertificateAuthority, StoreLocation.LocalMachine);
+ imStore.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite);
+ if (!imStore.IsOpen)
+ {
+ _log.Verbose("Unable to open intermediate certificate authority store");
+ imStore = new X509Store(_store.Name, StoreLocation.LocalMachine);
+ imStore.Open(OpenFlags.ReadWrite);
+ }
+ }
+ catch
+ {
+ _log.Warning("Error encountered while opening intermediate certificate store");
+ return;
+ }
+
+ foreach (var cert in chain)
+ {
+ try
+ {
+ _log.Verbose("{sub} - {iss} ({thumb}) to store {store}", cert.Subject, cert.Issuer, cert.Thumbprint, imStore.Name);
+ imStore.Add(cert);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning("Error saving certificate to store {store}: {message}", imStore.Name, ex.Message);
+ }
+ }
+
+ _log.Debug("Closing store {store}", imStore.Name);
+ imStore.Close();
+ }
+
+ private void UninstallCertificate(string thumbprint)
+ {
+ try
+ {
+ _store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error encountered while opening certificate store");
+ throw;
+ }
+
+ _log.Debug("Opened certificate store {Name}", _store.Name);
+ try
+ {
+ var col = _store.Certificates;
+ foreach (var cert in col)
+ {
+ if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.InvariantCultureIgnoreCase))
+ {
+ _log.Information(LogType.All, "Removing certificate {cert} from store {name}", cert.FriendlyName, _store.Name);
+ _store.Remove(cert);
+ }
+ }
+ _log.Debug("Closing certificate store");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error removing certificate");
+ throw;
+ }
+ _store.Close();
+ }
+
+ private X509Certificate2? GetCertificate(Func<X509Certificate2, bool> filter)
+ {
+ var possibles = new List<X509Certificate2>();
+ try
+ {
+ _store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error encountered while opening certificate store");
+ return null;
+ }
+ try
+ {
+ var col = _store.Certificates;
+ foreach (var cert in col)
+ {
+ if (filter(cert))
+ {
+ possibles.Add(cert);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Error finding certificate in certificate store");
+ return null;
+ }
+ _store.Close();
+ return possibles.OrderByDescending(x => x.NotBefore).FirstOrDefault();
+ }
+
+ (bool, string?) IPlugin.Disabled => Disabled(_userRoleService);
+
+ internal static (bool, string?) Disabled(IUserRoleService userRoleService)
+ {
+ if (userRoleService.IsAdmin)
+ {
+ return (false, null);
+ }
+ else
+ {
+ return (true, "Run as administrator to allow certificate store access.");
+ }
+ }
+
+ #region IDisposable
+
+ private bool disposedValue = false; // To detect redundant calls
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposedValue)
+ {
+ if (disposing)
+ {
+ _store.Dispose();
+ }
+ disposedValue = true;
+ }
+ }
+
+ public void Dispose() => Dispose(true);
+
+ #endregion
+ }
}
\ No newline at end of file diff --git a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs index 97f1d86..8c0adc7 100644 --- a/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs +++ b/src/main.lib/Plugins/StorePlugins/PemFiles/PemFiles.cs @@ -50,7 +50,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins } } - public Task Save(CertificateInfo input) + public async Task Save(CertificateInfo input) { _log.Information("Exporting .pem files to {folder}", _path); @@ -61,8 +61,9 @@ namespace PKISharp.WACS.Plugins.StorePlugins // Base certificate var certificateExport = input.Certificate.Export(X509ContentType.Cert); - var exportString = _pemService.GetPem("CERTIFICATE", certificateExport); - File.WriteAllText(Path.Combine(_path, $"{name}-crt.pem"), exportString); + var certString = _pemService.GetPem("CERTIFICATE", certificateExport);
+ var chainString = ""; + await File.WriteAllTextAsync(Path.Combine(_path, $"{name}-crt.pem"), certString); // Rest of the chain foreach (var chainCertificate in input.Chain) @@ -72,21 +73,20 @@ namespace PKISharp.WACS.Plugins.StorePlugins if (chainCertificate.Subject != chainCertificate.Issuer) { var chainCertificateExport = chainCertificate.Export(X509ContentType.Cert); - exportString += _pemService.GetPem("CERTIFICATE", chainCertificateExport); + chainString += _pemService.GetPem("CERTIFICATE", chainCertificateExport); } } // Save complete chain - File.WriteAllText(Path.Combine(_path, $"{name}-chain.pem"), exportString); - if (!input.StoreInfo.ContainsKey(GetType())) - { - input.StoreInfo.Add(GetType(), - new StoreInfo() - { - Name = PemFilesOptions.PluginName, - Path = _path - }); - } + await File.WriteAllTextAsync(Path.Combine(_path, $"{name}-chain.pem"), certString + chainString);
+ await File.WriteAllTextAsync(Path.Combine(_path, $"{name}-chain-only.pem"), chainString); + input.StoreInfo.TryAdd(
+ GetType(), + new StoreInfo() + { + Name = PemFilesOptions.PluginName, + Path = _path + }); // Private key if (input.CacheFile != null) @@ -97,7 +97,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins if (alias == null) { _log.Warning("No key entries found"); - return Task.CompletedTask; + return; } var entry = store.GetKey(alias); var key = entry.Key; @@ -107,7 +107,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins } if (!string.IsNullOrEmpty(pkPem)) { - File.WriteAllText(Path.Combine(_path, $"{name}-key.pem"), pkPem); + await File.WriteAllTextAsync(Path.Combine(_path, $"{name}-key.pem"), pkPem); } else { @@ -123,7 +123,6 @@ namespace PKISharp.WACS.Plugins.StorePlugins { _log.Error(ex, "Error exporting .pem files to folder"); } - return Task.CompletedTask; } public Task Delete(CertificateInfo input) => Task.CompletedTask; diff --git a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs index 8636eca..e86834b 100644 --- a/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs +++ b/src/main.lib/Plugins/StorePlugins/PfxFile/PfxFile.cs @@ -40,7 +40,7 @@ namespace PKISharp.WACS.Plugins.StorePlugins private string PathForIdentifier(string identifier) => Path.Combine(_path, $"{identifier.Replace("*", "_")}.pfx"); - public Task Save(CertificateInfo input) + public async Task Save(CertificateInfo input) { _log.Information("Copying certificate to the pfx folder"); var dest = PathForIdentifier(input.CommonName); @@ -51,19 +51,19 @@ namespace PKISharp.WACS.Plugins.StorePlugins input.Certificate }; collection.AddRange(input.Chain.ToArray()); - File.WriteAllBytes(dest, collection.Export(X509ContentType.Pfx, _password)); + await File.WriteAllBytesAsync(dest, collection.Export(X509ContentType.Pfx, _password)); } catch (Exception ex) { _log.Error(ex, "Error copying certificate to pfx path"); } - input.StoreInfo.Add(GetType(), + input.StoreInfo.TryAdd(
+ GetType(), new StoreInfo() { Name = PfxFileOptions.PluginName, Path = _path }); - return Task.CompletedTask; } public Task Delete(CertificateInfo input) => Task.CompletedTask; diff --git a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs index 1ce1d1c..206eb8f 100644 --- a/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs +++ b/src/main.lib/Plugins/TargetPlugins/IIS/IISOptionsFactory.cs @@ -577,7 +577,7 @@ namespace PKISharp.WACS.Plugins.TargetPlugins { return true; } - if (string.Equals(input, "s", StringComparison.CurrentCultureIgnoreCase)) + if (string.Equals(input, "s", StringComparison.InvariantCultureIgnoreCase)) { return true; } diff --git a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs index 0c39ec7..56752ee 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Dns/DnsValidation.cs @@ -46,13 +46,15 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins var success = false;
while (!success)
{
+ _log.Debug("[{identifier}] Attempting to create DNS record under {authority}...", context.Identifier, authority.Domain);
var record = new DnsValidationRecord(context, authority, challenge.DnsRecordValue);
success = await CreateRecord(record);
if (!success)
{
+ _log.Debug("[{identifier}] Failed to create record under {authority}", context.Identifier, authority.Domain);
if (authority.From == null)
{
- throw new Exception("Unable to prepare for challenge answer");
+ throw new Exception($"[{context.Identifier}] Unable to prepare for challenge answer");
}
else
{
@@ -61,6 +63,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins }
else
{
+ _log.Debug("[{identifier}] Record succesfully created", context.Identifier, authority.Domain);
_recordsCreated.Add(record);
}
}
diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs index 8dfccb4..c7e4c97 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/FileSystem/FileSystem.cs @@ -1,84 +1,88 @@ -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 -{ - internal class FileSystem : HttpValidation<FileSystemOptions, FileSystem> - { - protected IIISClient _iisClient; - - public FileSystem(FileSystemOptions options, IIISClient iisClient, RunLevel runLevel, HttpValidationParameters pars) : base(options, runLevel, pars) => _iisClient = iisClient; - - protected override Task DeleteFile(string path) - { - var fi = new FileInfo(path); - if (fi.Exists) - { - _log.Verbose("Deleting file {path}", path); - fi.Delete(); - } - else - { - _log.Warning("File {path} already deleted", path); - } - return Task.CompletedTask; - } - - protected override Task DeleteFolder(string path) - { - var di = new DirectoryInfo(path); - if (di.Exists) - { - _log.Verbose("Deleting folder {path}", path); - di.Delete(); - } - else - { - _log.Warning("Folder {path} already deleted", path); - } - return Task.CompletedTask; - } - - protected override Task<bool> IsEmpty(string path) => Task.FromResult(!new DirectoryInfo(path).EnumerateFileSystemInfos().Any()); - - protected override async Task WriteFile(string path, string content) - { - var fi = new FileInfo(path); - if (!fi.Directory.Exists) - { - fi.Directory.Create(); - } - _log.Verbose("Writing file to {path}", path); - await File.WriteAllTextAsync(path, content); - } - - /// <summary> - /// Update webroot - /// </summary> - /// <param name="scheduled"></param> - protected override void Refresh(TargetPart targetPart) - { - if (string.IsNullOrEmpty(_options.Path)) - { - // Update web root path - var siteId = _options.SiteId ?? targetPart.SiteId; - if (siteId > 0) - { - _path = _iisClient.GetWebSite(siteId.Value).Path; - } - else - { - throw new Exception("No path specified"); - } - } - else - { - _path = _options.Path; - } - } - } -} +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
+{
+ internal class FileSystem : HttpValidation<FileSystemOptions, FileSystem>
+ {
+ protected IIISClient _iisClient;
+
+ public FileSystem(FileSystemOptions options, IIISClient iisClient, RunLevel runLevel, HttpValidationParameters pars) : base(options, runLevel, pars) => _iisClient = iisClient;
+
+ protected override Task DeleteFile(string path)
+ {
+ var fi = new FileInfo(path);
+ if (fi.Exists)
+ {
+ _log.Verbose("Deleting file {path}", path);
+ fi.Delete();
+ }
+ else
+ {
+ _log.Warning("File {path} already deleted", path);
+ }
+ return Task.CompletedTask;
+ }
+
+ protected override Task DeleteFolder(string path)
+ {
+ var di = new DirectoryInfo(path);
+ if (di.Exists)
+ {
+ _log.Verbose("Deleting folder {path}", path);
+ di.Delete();
+ }
+ else
+ {
+ _log.Warning("Folder {path} already deleted", path);
+ }
+ return Task.CompletedTask;
+ }
+
+ protected override Task<bool> IsEmpty(string path)
+ {
+ var x = new DirectoryInfo(path);
+ return Task.FromResult(x.Exists && x.EnumerateFileSystemInfos().Any());
+ }
+
+ protected override async Task WriteFile(string path, string content)
+ {
+ var fi = new FileInfo(path);
+ if (!fi.Directory.Exists)
+ {
+ fi.Directory.Create();
+ }
+ _log.Verbose("Writing file to {path}", path);
+ await File.WriteAllTextAsync(path, content);
+ }
+
+ /// <summary>
+ /// Update webroot
+ /// </summary>
+ /// <param name="scheduled"></param>
+ protected override void Refresh(TargetPart targetPart)
+ {
+ if (string.IsNullOrEmpty(_options.Path))
+ {
+ // Update web root path
+ var siteId = _options.SiteId ?? targetPart.SiteId;
+ if (siteId > 0)
+ {
+ _path = _iisClient.GetWebSite(siteId.Value).Path;
+ }
+ else
+ {
+ throw new Exception("No path specified");
+ }
+ }
+ else
+ {
+ _path = _options.Path;
+ }
+ }
+ }
+}
diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs index ee6a996..a45e9e2 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs @@ -1,338 +1,338 @@ -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; -using System.Net.Http; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Plugins.ValidationPlugins -{ - /// <summary> - /// Base implementation for HTTP-01 validation plugins - /// </summary> - internal abstract class HttpValidation<TOptions, TPlugin> : - Validation<Http01ChallengeValidationDetails> - where TOptions : HttpValidationOptions<TPlugin> - where TPlugin : IValidationPlugin - { - private readonly List<string> _filesWritten = new List<string>(); - - protected TOptions _options; - protected ILogService _log; - protected IInputService _input; - protected ISettingsService _settings; - protected Renewal _renewal; - 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> - protected string? _path; - - /// <summary> - /// Provides proxy settings for site warmup - /// </summary> - private readonly ProxyService _proxy; - - /// <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"); - - /// <summary> - /// Character to seperate folders, different for FTP - /// </summary> - protected virtual char PathSeparator => '\\'; - - /// <summary> - /// Constructor - /// </summary> - /// <param name="log"></param> - /// <param name="input"></param> - /// <param name="options"></param> - /// <param name="proxy"></param> - /// <param name="renewal"></param> - /// <param name="target"></param> - /// <param name="runLevel"></param> - /// <param name="identifier"></param> - public HttpValidation(TOptions options, RunLevel runLevel, HttpValidationParameters pars) - { - _options = options; - _runLevel = runLevel; - _path = options.Path; - _log = pars.LogService; - _input = pars.InputService; - _proxy = pars.ProxyService; - _settings = pars.Settings; - _renewal = pars.Renewal; - } - - /// <summary> - /// Handle http challenge - /// </summary> - public async override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) - { - // 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, - UseShellExecute = true - }); - await _input.Wait(); - } - } - - string? foundValue = null; - try - { - var value = await WarmupSite(challenge); - if (Equals(value, challenge.HttpResourceValue)) - { - _log.Information("Preliminary validation looks good, but the ACME server will be more thorough"); - } - else - { - _log.Warning("Preliminary validation failed, the server answered '{value}' instead of '{expected}'. The ACME server might have a different perspective", - foundValue ?? "(null)", - challenge.HttpResourceValue); - } - } - catch (HttpRequestException hrex) - { - _log.Warning("Preliminary validation failed because '{hrex}'", hrex.Message); - } - catch (Exception ex) - { - _log.Error(ex, "Preliminary validation failed"); - } - } - - /// <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(Http01ChallengeValidationDetails challenge) - { - using var client = _proxy.GetHttpClient(false); - var response = await client.GetAsync(challenge.HttpResourceUrl); - return await response.Content.ReadAsStringAsync(); - } - - /// <summary> - /// Should create any directory structure needed and write the file for authorization - /// </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(Http01ChallengeValidationDetails challenge) - { - if (_path == null) - { - throw new InvalidOperationException(); - } - var path = CombinePath(_path, challenge.HttpResourcePath); - WriteFile(path, challenge.HttpResourceValue); - if (!_filesWritten.Contains(path)) - { - _filesWritten.Add(path); - } - } - - /// <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 WriteWebConfig(Http01ChallengeValidationDetails challenge) - { - if (_path == null) - { - throw new InvalidOperationException(); - } - if (_options.CopyWebConfig == true) - { - try - { - 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); ; - } - } - } - - /// <summary> - /// Get the template for the web.config - /// </summary> - /// <returns></returns> - private Lazy<string?> GetWebConfig() => new Lazy<string?>(() => { - try - { - return File.ReadAllText(TemplateWebConfig); - } - catch - { - return null; - } - }); - - /// <summary> - /// Combine root path with relative path - /// </summary> - /// <param name="root"></param> - /// <param name="path"></param> - /// <returns></returns> - protected virtual string CombinePath(string root, string path) - { - if (root == null) { root = string.Empty; } - var expandedRoot = Environment.ExpandEnvironmentVariables(root); - var trim = new[] { '/', '\\' }; - return $"{expandedRoot.TrimEnd(trim)}{PathSeparator}{path.TrimStart(trim).Replace('/', PathSeparator)}"; - } - - /// <summary> - /// Delete folder if it's empty - /// </summary> - /// <param name="path"></param> - /// <returns></returns> - private async Task<bool> DeleteFolderIfEmpty(string path) - { - if (await IsEmpty(path)) - { - await DeleteFolder(path); - return true; - } - else - { - _log.Debug("Additional files or folders exist in {folder}, not deleting.", path); - return false; - } - } - - /// <summary> - /// Write file with content to a specific location - /// </summary> - /// <param name="root"></param> - /// <param name="path"></param> - /// <param name="content"></param> - 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 Task DeleteFile(string path); - - /// <summary> - /// Check if folder is empty - /// </summary> - /// <param name="root"></param> - /// <param name="path"></param> - protected abstract Task<bool> IsEmpty(string path); - - /// <summary> - /// Delete folder if not empty - /// </summary> - /// <param name="root"></param> - /// <param name="path"></param> - protected abstract Task DeleteFolder(string path); - - /// <summary> - /// Refresh - /// </summary> - /// <param name="scheduled"></param> - /// <returns></returns> - protected virtual void Refresh(TargetPart targetPart) { } - - /// <summary> - /// Dispose - /// </summary> - public override async Task CleanUp() - { - 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); - } - } - } -} +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;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins
+{
+ /// <summary>
+ /// Base implementation for HTTP-01 validation plugins
+ /// </summary>
+ internal abstract class HttpValidation<TOptions, TPlugin> :
+ Validation<Http01ChallengeValidationDetails>
+ where TOptions : HttpValidationOptions<TPlugin>
+ where TPlugin : IValidationPlugin
+ {
+ private readonly List<string> _filesWritten = new List<string>();
+
+ protected TOptions _options;
+ protected ILogService _log;
+ protected IInputService _input;
+ protected ISettingsService _settings;
+ protected Renewal _renewal;
+ 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>
+ protected string? _path;
+
+ /// <summary>
+ /// Provides proxy settings for site warmup
+ /// </summary>
+ private readonly ProxyService _proxy;
+
+ /// <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");
+
+ /// <summary>
+ /// Character to seperate folders, different for FTP
+ /// </summary>
+ protected virtual char PathSeparator => '\\';
+
+ /// <summary>
+ /// Constructor
+ /// </summary>
+ /// <param name="log"></param>
+ /// <param name="input"></param>
+ /// <param name="options"></param>
+ /// <param name="proxy"></param>
+ /// <param name="renewal"></param>
+ /// <param name="target"></param>
+ /// <param name="runLevel"></param>
+ /// <param name="identifier"></param>
+ public HttpValidation(TOptions options, RunLevel runLevel, HttpValidationParameters pars)
+ {
+ _options = options;
+ _runLevel = runLevel;
+ _path = options.Path;
+ _log = pars.LogService;
+ _input = pars.InputService;
+ _proxy = pars.ProxyService;
+ _settings = pars.Settings;
+ _renewal = pars.Renewal;
+ }
+
+ /// <summary>
+ /// Handle http challenge
+ /// </summary>
+ public async override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge)
+ {
+ // 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,
+ UseShellExecute = true
+ });
+ await _input.Wait();
+ }
+ }
+
+ string? foundValue = null;
+ try
+ {
+ var value = await WarmupSite(challenge);
+ if (Equals(value, challenge.HttpResourceValue))
+ {
+ _log.Information("Preliminary validation looks good, but the ACME server will be more thorough");
+ }
+ else
+ {
+ _log.Warning("Preliminary validation failed, the server answered '{value}' instead of '{expected}'. The ACME server might have a different perspective",
+ foundValue ?? "(null)",
+ challenge.HttpResourceValue);
+ }
+ }
+ catch (HttpRequestException hrex)
+ {
+ _log.Warning("Preliminary validation failed because '{hrex}'", hrex.Message);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Preliminary validation failed");
+ }
+ }
+
+ /// <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(Http01ChallengeValidationDetails challenge)
+ {
+ using var client = _proxy.GetHttpClient(false);
+ var response = await client.GetAsync(challenge.HttpResourceUrl);
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ /// <summary>
+ /// Should create any directory structure needed and write the file for authorization
+ /// </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(Http01ChallengeValidationDetails challenge)
+ {
+ if (_path == null)
+ {
+ throw new InvalidOperationException();
+ }
+ var path = CombinePath(_path, challenge.HttpResourcePath);
+ WriteFile(path, challenge.HttpResourceValue);
+ if (!_filesWritten.Contains(path))
+ {
+ _filesWritten.Add(path);
+ }
+ }
+
+ /// <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 WriteWebConfig(Http01ChallengeValidationDetails challenge)
+ {
+ if (_path == null)
+ {
+ throw new InvalidOperationException();
+ }
+ if (_options.CopyWebConfig == true)
+ {
+ try
+ {
+ 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); ;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Get the template for the web.config
+ /// </summary>
+ /// <returns></returns>
+ private Lazy<string?> GetWebConfig() => new Lazy<string?>(() => {
+ try
+ {
+ return File.ReadAllText(TemplateWebConfig);
+ }
+ catch
+ {
+ return null;
+ }
+ });
+
+ /// <summary>
+ /// Combine root path with relative path
+ /// </summary>
+ /// <param name="root"></param>
+ /// <param name="path"></param>
+ /// <returns></returns>
+ protected virtual string CombinePath(string root, string path)
+ {
+ if (root == null) { root = string.Empty; }
+ var expandedRoot = Environment.ExpandEnvironmentVariables(root);
+ var trim = new[] { '/', '\\' };
+ return $"{expandedRoot.TrimEnd(trim)}{PathSeparator}{path.TrimStart(trim).Replace('/', PathSeparator)}";
+ }
+
+ /// <summary>
+ /// Delete folder if it's empty
+ /// </summary>
+ /// <param name="path"></param>
+ /// <returns></returns>
+ private async Task<bool> DeleteFolderIfEmpty(string path)
+ {
+ if (await IsEmpty(path))
+ {
+ await DeleteFolder(path);
+ return true;
+ }
+ else
+ {
+ _log.Debug("Not deleting {path} because it doesn't exist or it's not empty.", path);
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// Write file with content to a specific location
+ /// </summary>
+ /// <param name="root"></param>
+ /// <param name="path"></param>
+ /// <param name="content"></param>
+ 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 Task DeleteFile(string path);
+
+ /// <summary>
+ /// Check if folder is empty
+ /// </summary>
+ /// <param name="root"></param>
+ /// <param name="path"></param>
+ protected abstract Task<bool> IsEmpty(string path);
+
+ /// <summary>
+ /// Delete folder if not empty
+ /// </summary>
+ /// <param name="root"></param>
+ /// <param name="path"></param>
+ protected abstract Task DeleteFolder(string path);
+
+ /// <summary>
+ /// Refresh
+ /// </summary>
+ /// <param name="scheduled"></param>
+ /// <returns></returns>
+ protected virtual void Refresh(TargetPart targetPart) { }
+
+ /// <summary>
+ /// Dispose
+ /// </summary>
+ public override async Task CleanUp()
+ {
+ 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.Error(ex, "Error occured while deleting folder structure");
+ }
+ }
+ }
+}
diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs index c81966f..3681c8d 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/SelfHosting/SelfHosting.cs @@ -51,7 +51,7 @@ namespace PKISharp.WACS.Plugins.ValidationPlugins.Http private async Task ReceiveRequests() { - while (Listener.IsListening) + while (HasListener && Listener.IsListening) { var ctx = await Listener.GetContextAsync(); var path = ctx.Request.Url.LocalPath; diff --git a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs index f1adaea..16bc141 100644 --- a/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs +++ b/src/main.lib/Plugins/ValidationPlugins/Http/WebDav/WebDav.cs @@ -1,34 +1,31 @@ -using ACMESharp.Authorizations; -using PKISharp.WACS.Client; -using PKISharp.WACS.Context; -using PKISharp.WACS.Services; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Plugins.ValidationPlugins.Http -{ - internal class WebDav : HttpValidation<WebDavOptions, WebDav> - { - private readonly WebDavClientWrapper _webdavClient; - - public WebDav( - WebDavOptions options, HttpValidationParameters pars, - RunLevel runLevel, ProxyService proxy) : - base(options, runLevel, pars) => _webdavClient = new WebDavClientWrapper(_options.Credential, pars.LogService, proxy); - - protected override async Task DeleteFile(string path) => _webdavClient.Delete(path); - - protected override async Task DeleteFolder(string path) => _webdavClient.Delete(path); - - protected override async Task<bool> IsEmpty(string path) => !_webdavClient.IsEmpty(path); - - protected override char PathSeparator => '/'; - - protected override async Task WriteFile(string path, string content) => _webdavClient.Upload(path, content); - public override Task CleanUp() - { - base.CleanUp(); - _webdavClient.Dispose(); - return Task.CompletedTask; - } - } -} +using PKISharp.WACS.Client;
+using PKISharp.WACS.Services;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins.Http
+{
+ internal class WebDav : HttpValidation<WebDavOptions, WebDav>
+ {
+ private readonly WebDavClientWrapper _webdavClient;
+
+ public WebDav(
+ WebDavOptions options, HttpValidationParameters pars,
+ RunLevel runLevel, ProxyService proxy) :
+ base(options, runLevel, pars) => _webdavClient = new WebDavClientWrapper(_options.Credential, pars.LogService, proxy);
+
+ protected override async Task DeleteFile(string path) => _webdavClient.Delete(path);
+
+ protected override async Task DeleteFolder(string path) => _webdavClient.Delete(path);
+
+ protected override async Task<bool> IsEmpty(string path) => !_webdavClient.IsEmpty(path);
+
+ protected override char PathSeparator => '/';
+
+ protected override async Task WriteFile(string path, string content) => _webdavClient.Upload(path, content);
+ public override async Task CleanUp()
+ {
+ await base.CleanUp();
+ _webdavClient.Dispose();
+ }
+ }
+}
diff --git a/src/main.lib/RenewalManager.cs b/src/main.lib/RenewalManager.cs index b4a4aff..e563d78 100644 --- a/src/main.lib/RenewalManager.cs +++ b/src/main.lib/RenewalManager.cs @@ -2,7 +2,6 @@ using PKISharp.WACS.Configuration; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Extensions; -using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Plugins.TargetPlugins; using PKISharp.WACS.Services; using System; @@ -174,7 +173,7 @@ namespace PKISharp.WACS options.Add( Choice.Create<Func<Task>>( async () => selectedRenewals = await Analyze(selectedRenewals), - $"Analyze duplicates for {selectionLabel}", "A", + $"Analyze duplicates for {selectionLabel}", "U", @disabled: (none, "No renewals selected."))); options.Add( Choice.Create<Func<Task>>( @@ -555,9 +554,24 @@ namespace PKISharp.WACS foreach (var ipo in renewal.InstallationPluginOptions) { ipo.Show(_input); - } - _input.Show("History"); - await _input.WritePagedList(renewal.History.Select(x => Choice.Create(x))); + }
+ var historyLimit = 10;
+ if (renewal.History.Count <= historyLimit)
+ {
+ _input.Show("History");
+ }
+ else
+ {
+ _input.Show($"History (most recent {historyLimit} of {renewal.History.Count} entries)"); +
+ }
+ await _input.WritePagedList(
+ renewal.History.
+ AsEnumerable().
+ Reverse().
+ Take(historyLimit).
+ Reverse().
+ Select(x => Choice.Create(x))); } catch (Exception ex) { diff --git a/src/main.lib/RenewalValidator.cs b/src/main.lib/RenewalValidator.cs index 3421d07..9fee240 100644 --- a/src/main.lib/RenewalValidator.cs +++ b/src/main.lib/RenewalValidator.cs @@ -10,7 +10,6 @@ using PKISharp.WACS.Services; using System; using System.Collections.Generic; using System.Linq; -using System.Security.Policy; using System.Threading.Tasks; namespace PKISharp.WACS @@ -64,7 +63,7 @@ namespace PKISharp.WACS !runLevel.HasFlag(RunLevel.IgnoreCache)) { return; - } + }
else { orderValid = true; @@ -98,7 +97,7 @@ namespace PKISharp.WACS return; } - if (_settings.Validation.DisableMultiThreading == true || + if (_settings.Validation.DisableMultiThreading == true ||
validationPlugin.Parallelism == ParallelOperations.None) { await SerialValidation(context, contextParams); @@ -109,7 +108,7 @@ namespace PKISharp.WACS } } - /// <summary> + /// <summary>+ /// Handle multiple validations in parallel /// </summary> /// <returns></returns> @@ -117,81 +116,84 @@ namespace PKISharp.WACS { 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) + try
{
- context.Result.AddErrorMessage("Commit failed"); - return; - } - - // Submit challenge answer - var contextsWithChallenges = contexts.Where(x => x.ChallengeDetails != null); - if (contextsWithChallenges.Any()) - { - if (level.HasFlag(ParallelOperations.Answer)) - { - // Parallel - _log.Verbose("Handle {n} answers(s)", contextsWithChallenges.Count()); - var answerTasks = contexts.Select(vc => AnswerChallenge(vc)); - await Task.WhenAll(answerTasks); - foreach (var ctx in contextsWithChallenges) - { - TransferErrors(ctx, context.Result); - } - if (!context.Result.Success) - { - return; - } - } - else - { - // Serial - foreach (var ctx in contextsWithChallenges) - { - await AnswerChallenge(ctx); - TransferErrors(ctx, context.Result); - if (!context.Result.Success) - { - return; - } - } - } - - // Cleanup - await CleanValidation(contexts.First().ValidationPlugin); + // Prepare for challenge answer
+ if (level.HasFlag(ParallelOperations.Prepare))
+ {
+ // Parallel
+ _log.Verbose("Handle {n} preparation(s)", contexts.Count);
+ var prepareTasks = contexts.Select(vc => PrepareChallengeAnswer(vc, context.RunLevel));
+ await Task.WhenAll(prepareTasks);
+ foreach (var ctx in contexts)
+ {
+ TransferErrors(ctx, context.Result);
+ }
+ if (!context.Result.Success)
+ {
+ return;
+ }
+ }
+ else
+ {
+ // Serial
+ foreach (var ctx in contexts)
+ {
+ await PrepareChallengeAnswer(ctx, context.RunLevel);
+ TransferErrors(ctx, context.Result);
+ if (!context.Result.Success)
+ {
+ return;
+ }
+ }
+ }
+
+ // Commit
+ var commited = await CommitValidation(plugin);
+ if (!commited)
+ {
+ context.Result.AddErrorMessage("Commit failed");
+ return;
+ }
+
+ // Submit challenge answer
+ var contextsWithChallenges = contexts.Where(x => x.ChallengeDetails != null);
+ if (contextsWithChallenges.Any())
+ {
+ if (level.HasFlag(ParallelOperations.Answer))
+ {
+ // Parallel
+ _log.Verbose("Handle {n} answers(s)", contextsWithChallenges.Count());
+ var answerTasks = contexts.Select(vc => AnswerChallenge(vc));
+ await Task.WhenAll(answerTasks);
+ foreach (var ctx in contextsWithChallenges)
+ {
+ TransferErrors(ctx, context.Result);
+ }
+ if (!context.Result.Success)
+ {
+ return;
+ }
+ }
+ else
+ {
+ // Serial
+ foreach (var ctx in contextsWithChallenges)
+ {
+ await AnswerChallenge(ctx);
+ TransferErrors(ctx, context.Result);
+ if (!context.Result.Success)
+ {
+ return;
+ }
+ }
+ }
+ }
+ }
+ finally
+ {
+ // Cleanup
+ await CleanValidation(plugin);
} } @@ -206,7 +208,7 @@ namespace PKISharp.WACS foreach (var parameter in parameters) { _log.Verbose("Handle authorization {n}/{m}", - parameters.IndexOf(parameter) + 1, + 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 }); @@ -232,7 +234,7 @@ namespace PKISharp.WACS try { authorization = await client.GetAuthorizationDetails(authorizationUri); - } + }
catch { context.Result.AddErrorMessage($"Unable to get authorization details from {authorizationUri}", !orderValid); @@ -263,9 +265,9 @@ namespace PKISharp.WACS { 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> @@ -295,7 +297,7 @@ namespace PKISharp.WACS _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)); + var challenge = context.Authorization.Challenges.FirstOrDefault(c => string.Equals(c.Type, context.ChallengeType, StringComparison.InvariantCultureIgnoreCase)); if (challenge == null) { if (context.Success == true) @@ -383,8 +385,8 @@ namespace PKISharp.WACS _log.Error("[{identifier}] Authorization result: {Status}", validationContext.Identifier, updatedChallenge.Status); if (updatedChallenge.Error != null) { - _log.Error("[{identifier}] {Error}", validationContext.Identifier, updatedChallenge.Error.ToString()); - + _log.Error("[{identifier}] {Error}", validationContext.Identifier, updatedChallenge.Error.ToString());
+
} validationContext.AddErrorMessage("Validation failed", validationContext.Success == false); return; diff --git a/src/main.lib/Services/ArgumentsParser.cs b/src/main.lib/Services/ArgumentsParser.cs index ea82ebc..a1e7b48 100644 --- a/src/main.lib/Services/ArgumentsParser.cs +++ b/src/main.lib/Services/ArgumentsParser.cs @@ -37,7 +37,7 @@ namespace PKISharp.WACS.Configuration var result = _providers.First().GetParseResult(_args); foreach (var add in result.AdditionalOptionsFound) { - var super = superset.FirstOrDefault(x => string.Equals(x.LongName, add.Key, StringComparison.CurrentCultureIgnoreCase)); + var super = superset.FirstOrDefault(x => string.Equals(x.LongName, add.Key, StringComparison.InvariantCultureIgnoreCase)); if (super == null) { _log.Error("Unknown argument --{0}", add.Key); diff --git a/src/main.lib/Services/CertificateService.cs b/src/main.lib/Services/CertificateService.cs index 949c9f5..ad2f0bf 100644 --- a/src/main.lib/Services/CertificateService.cs +++ b/src/main.lib/Services/CertificateService.cs @@ -325,7 +325,7 @@ namespace PKISharp.WACS.Services 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));
+ await File.WriteAllTextAsync(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));
}
@@ -408,7 +408,7 @@ namespace PKISharp.WACS.Services ClearCache(order.Renewal, postfix: $"*{PfxPostFix}");
ClearCache(order.Renewal, postfix: $"*{PfxPostFixLegacy}");
- File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value));
+ await File.WriteAllBytesAsync(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.",
@@ -432,7 +432,7 @@ namespace PKISharp.WACS.Services {
newVersion.FriendlyName = friendlyName;
tempPfx[certIndex] = newVersion;
- File.WriteAllBytes(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value));
+ await File.WriteAllBytesAsync(pfxFileInfo.FullName, tempPfx.Export(X509ContentType.Pfx, order.Renewal.PfxPassword?.Value));
newVersion.Dispose();
}
}
diff --git a/src/main.lib/Services/Legacy/Importer.cs b/src/main.lib/Services/Legacy/Importer.cs index efa06b3..b6d7db7 100644 --- a/src/main.lib/Services/Legacy/Importer.cs +++ b/src/main.lib/Services/Legacy/Importer.cs @@ -274,7 +274,15 @@ namespace PKISharp.WACS.Services.Legacy StoreName = legacy.CertificateStore, KeepExisting = legacy.KeepExisting == true }); - } + }
+ ret.StorePluginOptions.Add(new store.PemFilesOptions()
+ {
+ Path = _settings.Cache.Path
+ });
+ ret.StorePluginOptions.Add(new store.PfxFileOptions()
+ {
+ Path = _settings.Cache.Path
+ }); } public void ConvertInstallation(LegacyScheduledRenewal legacy, Renewal ret) diff --git a/src/main.lib/Services/ProxyService.cs b/src/main.lib/Services/ProxyService.cs index 9287ed1..17e45fd 100644 --- a/src/main.lib/Services/ProxyService.cs +++ b/src/main.lib/Services/ProxyService.cs @@ -1,102 +1,102 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Security.Authentication; -using System.Threading; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Services -{ - public class ProxyService - { - private readonly ILogService _log; - private IWebProxy? _proxy; - private readonly ISettingsService _settings; - public SslProtocols SslProtocols { get; set; } = SslProtocols.None; - - public ProxyService(ILogService log, ISettingsService settings) - { - _log = log; - _settings = settings; - } - - /// <summary> - /// Is the user requesting the system proxy - /// </summary> - public bool UseSystemProxy => string.Equals(_settings.Proxy.Url, "[System]", StringComparison.OrdinalIgnoreCase); - - /// <summary> - /// Get prepared HttpClient with correct system proxy settings - /// </summary> - /// <returns></returns> - public HttpClient GetHttpClient(bool checkSsl = true) - { - var httpClientHandler = new LoggingHttpClientHandler(_log) - { - Proxy = GetWebProxy(), - SslProtocols = SslProtocols - }; - if (!checkSsl) - { - httpClientHandler.ServerCertificateCustomValidationCallback = (a, b, c, d) => true; - } - if (UseSystemProxy) - { - httpClientHandler.DefaultProxyCredentials = CredentialCache.DefaultCredentials; - } - 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> - /// <returns></returns> - public IWebProxy? GetWebProxy() - { - if (_proxy == null) - { - var proxy = UseSystemProxy ? - null : - string.IsNullOrEmpty(_settings.Proxy.Url) ? - new WebProxy() : - new WebProxy(_settings.Proxy.Url); - if (proxy != null) - { - var testUrl = new Uri("http://proxy.example.com"); - var proxyUrl = proxy.GetProxy(testUrl); - - if (!string.IsNullOrWhiteSpace(_settings.Proxy.Username)) - { - proxy.Credentials = new NetworkCredential( - _settings.Proxy.Username, - _settings.Proxy.Password); - } - - var useProxy = !string.Equals(testUrl.Host, proxyUrl.Host); - if (useProxy) - { - _log.Warning("Proxying via {proxy}:{port}", proxyUrl.Host, proxyUrl.Port); - } - } - _proxy = proxy; - } - return _proxy; - } - - } -} +using System;
+using System.Net;
+using System.Net.Http;
+using System.Security.Authentication;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Services
+{
+ public class ProxyService
+ {
+ private readonly ILogService _log;
+ private IWebProxy? _proxy;
+ private readonly ISettingsService _settings;
+ public SslProtocols SslProtocols { get; set; } = SslProtocols.None;
+
+ public ProxyService(ILogService log, ISettingsService settings)
+ {
+ _log = log;
+ _settings = settings;
+ }
+
+ /// <summary>
+ /// Is the user requesting the system proxy
+ /// </summary>
+ public bool UseSystemProxy => string.Equals(_settings.Proxy.Url, "[System]", StringComparison.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// Get prepared HttpClient with correct system proxy settings
+ /// </summary>
+ /// <returns></returns>
+ public HttpClient GetHttpClient(bool checkSsl = true)
+ {
+ var httpClientHandler = new LoggingHttpClientHandler(_log)
+ {
+ Proxy = GetWebProxy(),
+ SslProtocols = SslProtocols
+ };
+ if (!checkSsl)
+ {
+ httpClientHandler.ServerCertificateCustomValidationCallback = (a, b, c, d) => true;
+ }
+ if (UseSystemProxy)
+ {
+ httpClientHandler.DefaultProxyCredentials = CredentialCache.DefaultCredentials;
+ }
+ 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>
+ /// <returns></returns>
+ public IWebProxy? GetWebProxy()
+ {
+ if (_proxy == null)
+ {
+ var proxy = UseSystemProxy ?
+ null :
+ string.IsNullOrEmpty(_settings.Proxy.Url) ?
+ new WebProxy() :
+ new WebProxy(_settings.Proxy.Url);
+ if (proxy != null)
+ {
+ var testUrl = new Uri("http://proxy.example.com");
+ var proxyUrl = proxy.GetProxy(testUrl);
+
+ if (!string.IsNullOrWhiteSpace(_settings.Proxy.Username))
+ {
+ proxy.Credentials = new NetworkCredential(
+ _settings.Proxy.Username,
+ _settings.Proxy.Password);
+ }
+
+ var useProxy = !string.Equals(testUrl.Host, proxyUrl.Host);
+ if (useProxy)
+ {
+ _log.Warning("Proxying via {proxy}:{port}", proxyUrl.Host, proxyUrl.Port);
+ }
+ }
+ _proxy = proxy;
+ }
+ return _proxy;
+ }
+
+ }
+}
diff --git a/src/main.lib/Services/RenewalStore.cs b/src/main.lib/Services/RenewalStore.cs index a061417..9ba8cc3 100644 --- a/src/main.lib/Services/RenewalStore.cs +++ b/src/main.lib/Services/RenewalStore.cs @@ -48,7 +48,7 @@ namespace PKISharp.WACS.Services } if (!string.IsNullOrEmpty(id)) { - ret = ret.Where(x => string.Equals(id, x.Id, StringComparison.CurrentCultureIgnoreCase)); + ret = ret.Where(x => string.Equals(id, x.Id, StringComparison.InvariantCultureIgnoreCase)); } return ret; } diff --git a/src/main.lib/Services/RenewalStoreDisk.cs b/src/main.lib/Services/RenewalStoreDisk.cs index ca35b1d..a37f90f 100644 --- a/src/main.lib/Services/RenewalStoreDisk.cs +++ b/src/main.lib/Services/RenewalStoreDisk.cs @@ -142,13 +142,28 @@ namespace PKISharp.WACS.Services if (file != null) { try - { - File.WriteAllText(file.FullName, JsonConvert.SerializeObject(renewal, new JsonSerializerSettings + {
+ var renewalContent = JsonConvert.SerializeObject(renewal, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.Indented, Converters = { new ProtectedStringConverter(_log, _settings) } - })); + });
+ if (string.IsNullOrWhiteSpace(renewalContent))
+ {
+ throw new Exception("Serialization yielded empty result");
+ }
+ if (file.Exists)
+ {
+ File.WriteAllText(file.FullName + ".new", renewalContent); + File.Replace(file.FullName + ".new", file.FullName, file.FullName + ".previous", true);
+ File.Delete(file.FullName + ".previous");
+ }
+ else
+ {
+ File.WriteAllText(file.FullName, renewalContent);
+ } + } catch (Exception ex) { diff --git a/src/main.lib/wacs.lib.csproj b/src/main.lib/wacs.lib.csproj index ddb25ba..1b7752d 100644 --- a/src/main.lib/wacs.lib.csproj +++ b/src/main.lib/wacs.lib.csproj @@ -25,11 +25,11 @@ <ItemGroup> <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="MailKit" Version="2.8.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.6" /> <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" /> @@ -47,8 +47,8 @@ <PackageReference Include="System.Security.Cryptography.Cng" Version="4.7.0" /> <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.6.0" /> + <PackageReference Include="TaskScheduler" Version="2.8.20" /> + <PackageReference Include="WebDav.Client" Version="2.7.0" /> </ItemGroup> <ItemGroup> diff --git a/src/main.test/Mock/Services/InputService.cs b/src/main.test/Mock/Services/InputService.cs index 328f0f5..6bcda82 100644 --- a/src/main.test/Mock/Services/InputService.cs +++ b/src/main.test/Mock/Services/InputService.cs @@ -28,7 +28,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services { return Task. FromResult(options.Select(o => creator(o)). - FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.CurrentCultureIgnoreCase)). + FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.InvariantCultureIgnoreCase)). Item); } } @@ -40,14 +40,14 @@ namespace PKISharp.WACS.UnitTests.Mock.Services var input = GetNextInput(); return Task. FromResult(options.Select(o => creator(o)). - FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.CurrentCultureIgnoreCase)).Item); + FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.InvariantCultureIgnoreCase)).Item); } public string FormatDate(DateTime date) => ""; public Task<bool> PromptYesNo(string message, bool defaultOption) { var input = GetNextInput(); - return Task.FromResult(string.Equals(input, "y", StringComparison.CurrentCultureIgnoreCase)); + return Task.FromResult(string.Equals(input, "y", StringComparison.InvariantCultureIgnoreCase)); } public Task<string?> ReadPassword(string what) => Task.FromResult<string?>(GetNextInput()); public Task<string> RequestString(string what) => Task.FromResult(GetNextInput()); @@ -58,7 +58,7 @@ namespace PKISharp.WACS.UnitTests.Mock.Services public Task<TResult> ChooseFromMenu<TResult>(string what, List<Choice<TResult>> choices, Func<string, Choice<TResult>>? unexpected = null) { var input = GetNextInput(); - var choice = choices.FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.CurrentCultureIgnoreCase)); + var choice = choices.FirstOrDefault(c => string.Equals(c.Command, input, StringComparison.InvariantCultureIgnoreCase)); if (choice == null && unexpected != null) { choice = unexpected(input); diff --git a/src/main.test/wacs.test.csproj b/src/main.test/wacs.test.csproj index 35e2c74..9b7b870 100644 --- a/src/main.test/wacs.test.csproj +++ b/src/main.test/wacs.test.csproj @@ -16,8 +16,8 @@ <ItemGroup> <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="MSTest.TestAdapter" Version="2.1.2" /> + <PackageReference Include="MSTest.TestFramework" Version="2.1.2" /> <PackageReference Include="coverlet.collector" Version="1.3.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/src/main/Program.cs b/src/main/Program.cs index e02a212..0c1a2ba 100644 --- a/src/main/Program.cs +++ b/src/main/Program.cs @@ -23,15 +23,16 @@ namespace PKISharp.WACS.Host AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(OnUnhandledException); - // Uncomment to debug with a local proxy like Fiddler - // System.Net.ServicePointManager.ServerCertificateValidationCallback += - // (sender, cert, chain, sslPolicyErrors) => true; - // Setup IOC container var container = GlobalScope(args); if (container == null) { Environment.ExitCode = -1; + if (Environment.UserInteractive)
+ {
+ Console.WriteLine(" Press <Enter> to close");
+ Console.ReadLine();
+ } return; } diff --git a/src/main/settings.json b/src/main/settings.json index d40bd2d..c90732a 100644 --- a/src/main/settings.json +++ b/src/main/settings.json @@ -1,98 +1,98 @@ -{ - "Client": { - "ClientName": "win-acme", - "ConfigurationPath": null, - "LogPath": null - }, - "UI": { - "PageSize": 50, - "DateFormat": "yyyy/M/d H:mm:ss", - "TextEncoding": "utf-8" - }, - "Acme": { - "DefaultBaseUri": "https://acme-v02.api.letsencrypt.org/", - "DefaultBaseUriTest": "https://acme-staging-v02.api.letsencrypt.org/", - "DefaultBaseUriImport": "https://acme-v01.api.letsencrypt.org/", - "PostAsGet": true, - "RetryCount": 15, - "RetryInterval": 5 - }, - "Proxy": { - "Url": "[System]", - "UserName": null, - "Password": null - }, - "Cache": { - "ReuseDays": 1, - "DeleteStaleFiles": false, - "Path": null - }, - "ScheduledTask": { - "RenewalDays": 55, - "RandomDelay": "00:00:00", - "StartBoundary": "09:00:00", - "ExecutionTimeLimit": "02:00:00" - }, - "Notification": { - "SmtpServer": null, - "SmtpPort": 25, - "SmtpUser": null, - "SmtpPassword": null, - "SmtpSecure": false, - "SmtpSecureMode": 1, - "SenderName": null, - "SenderAddress": null, - "ReceiverAddresses": [], - "EmailOnSuccess": false, - "ComputerName": null - }, - "Security": { - "RSAKeyBits": 3072, - "ECCurve": "secp384r1", - "PrivateKeyExportable": false, - "EncryptConfig": true - }, - "Script": { - "Timeout": 600 - }, - "Target": { - "DefaultTarget": null - }, - "Validation": { - "DefaultValidation": null, - "DefaultValidationMode": null, - "DisableMultiThreading": true, - "CleanupFolders": true, - "PreValidateDns": true, - "PreValidateDnsRetryCount": 5, - "PreValidateDnsRetryInterval": 30, - "AllowDnsSubstitution": true, - "DnsServers": [ "8.8.8.8", "1.1.1.1", "8.8.4.4" ] - }, - "Order": { - "DefaultOrder": null - }, - "Csr": { - "DefaultCsr": null - }, - "Store": { - "DefaultStore": null, - "CertificateStore": { - "DefaultStore": null - }, - "CentralSsl": { - "DefaultPath": null, - "DefaultPassword": null - }, - "PemFiles": { - "DefaultPath": null - }, - "PfxFile": { - "DefaultPath": null, - "DefaultPassword": null - } - }, - "Installation": { - "DefaultInstallation": null - } -} +{
+ "Client": {
+ "ClientName": "win-acme",
+ "ConfigurationPath": null,
+ "LogPath": null
+ },
+ "UI": {
+ "PageSize": 50,
+ "DateFormat": "yyyy/M/d H:mm:ss",
+ "TextEncoding": "utf-8"
+ },
+ "Acme": {
+ "DefaultBaseUri": "https://acme-v02.api.letsencrypt.org/",
+ "DefaultBaseUriTest": "https://acme-staging-v02.api.letsencrypt.org/",
+ "DefaultBaseUriImport": "https://acme-v01.api.letsencrypt.org/",
+ "PostAsGet": true,
+ "RetryCount": 15,
+ "RetryInterval": 5
+ },
+ "Proxy": {
+ "Url": "[System]",
+ "UserName": null,
+ "Password": null
+ },
+ "Cache": {
+ "ReuseDays": 1,
+ "DeleteStaleFiles": false,
+ "Path": null
+ },
+ "ScheduledTask": {
+ "RenewalDays": 55,
+ "RandomDelay": "00:00:00",
+ "StartBoundary": "09:00:00",
+ "ExecutionTimeLimit": "02:00:00"
+ },
+ "Notification": {
+ "SmtpServer": null,
+ "SmtpPort": 25,
+ "SmtpUser": null,
+ "SmtpPassword": null,
+ "SmtpSecure": false,
+ "SmtpSecureMode": 1,
+ "SenderName": null,
+ "SenderAddress": null,
+ "ReceiverAddresses": [],
+ "EmailOnSuccess": false,
+ "ComputerName": null
+ },
+ "Security": {
+ "RSAKeyBits": 3072,
+ "ECCurve": "secp384r1",
+ "PrivateKeyExportable": false,
+ "EncryptConfig": true
+ },
+ "Script": {
+ "Timeout": 600
+ },
+ "Target": {
+ "DefaultTarget": null
+ },
+ "Validation": {
+ "DefaultValidation": null,
+ "DefaultValidationMode": null,
+ "DisableMultiThreading": true,
+ "CleanupFolders": true,
+ "PreValidateDns": true,
+ "PreValidateDnsRetryCount": 5,
+ "PreValidateDnsRetryInterval": 30,
+ "AllowDnsSubstitution": true,
+ "DnsServers": [ "8.8.8.8", "1.1.1.1", "8.8.4.4" ]
+ },
+ "Order": {
+ "DefaultOrder": null
+ },
+ "Csr": {
+ "DefaultCsr": null
+ },
+ "Store": {
+ "DefaultStore": 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 2a0c741..1fa4ef1 100755..100644 --- a/src/plugin.validation.dns.azure/Azure.cs +++ b/src/plugin.validation.dns.azure/Azure.cs @@ -1,242 +1,278 @@ -using Microsoft.Azure.Management.Dns; -using Microsoft.Azure.Management.Dns.Models; -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 ProxyService _proxyService; - private readonly AzureOptions _options; - private readonly Dictionary<string, Dictionary<string, RecordSet>> _recordSets; - private IEnumerable<Zone> _hostedZones; - - public Azure(AzureOptions options, - LookupClientProvider dnsClient, - ProxyService proxyService, - ILogService log, - ISettingsService settings) : base(dnsClient, log, settings) - { - _options = options; - _proxyService = proxyService; - _recordSets = new Dictionary<string, Dictionary<string, RecordSet>>(); - } - - /// <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 zone = await GetHostedZone(record.Authority.Domain); - if (zone == null) - { - return false; - } - // Create or update record set parameters - var txtRecord = new TxtRecord(new[] { record.Value }); - if (!_recordSets.ContainsKey(zone)) - { - _recordSets.Add(zone, new Dictionary<string, RecordSet>()); - } - var zoneRecords = _recordSets[zone]; - var relativeKey = RelativeRecordName(zone, record.Authority.Domain); - if (!zoneRecords.ContainsKey(relativeKey)) - { - zoneRecords.Add( - relativeKey, - new RecordSet - { - TTL = 0, - TxtRecords = new List<TxtRecord> { txtRecord } - }); - } - else - { - zoneRecords[relativeKey].TxtRecords.Add(txtRecord); - } - return true; - } - - /// <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) - { - foreach (var domain in _recordSets[zone].Keys) - { - updateTasks.Add(CreateOrUpdateRecordSet(zone, domain)); - } - } - await Task.WhenAll(updateTasks); - } - - /// <summary> - /// Store a single recordset - /// </summary> - /// <param name="zone"></param> - /// <param name="domain"></param> - /// <param name="recordSet"></param> - /// <returns></returns> - private async Task CreateOrUpdateRecordSet(string zone, string domain) - { - try - { - 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() - { - if (_azureDnsClient == null) - { - // Build the service credentials and DNS management client - ServiceClientCredentials credentials; - - // Decide between Managed Service Identity (MSI) and service principal with client credentials - if (_options.UseMsi) - { - var azureServiceTokenProvider = new AzureServiceTokenProvider(); - var accessToken = await azureServiceTokenProvider.GetAccessTokenAsync("https://management.azure.com/"); - credentials = new TokenCredentials(accessToken); - } - else - { - credentials = await ApplicationTokenProvider.LoginSilentAsync( - _options.TenantId, - _options.ClientId, - _options.Secret.Value); - } - - _azureDnsClient = new DnsManagementClient(credentials, _proxyService.GetHttpClient(), true) - { - SubscriptionId = _options.SubscriptionId - }; - } - return _azureDnsClient; - } - - /// <summary> - /// Translate full host name to zone relative name - /// </summary> - /// <param name="zone"></param> - /// <param name="recordName"></param> - /// <returns></returns> - private string RelativeRecordName(string zone, string recordName) - { - var ret = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.'); - return string.IsNullOrEmpty(ret) ? "@" : ret; - } - - /// <summary> - /// Find the approriate hosting zone to use for record updates - /// </summary> - /// <param name="recordName"></param> - /// <returns></returns> - private async Task<string> GetHostedZone(string recordName) - { - // Cache so we don't have to repeat this more than once for each renewal - if (_hostedZones == null) - { - var client = await GetClient(); - var zones = new List<Zone>(); - var response = await client.Zones.ListByResourceGroupAsync(_options.ResourceGroupName); - zones.AddRange(response); - while (!string.IsNullOrEmpty(response.NextPageLink)) - { - response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink); - } - _log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones.Count, _options.ResourceGroupName); - _hostedZones = zones; - } - - var hostedZone = FindBestMatch(_hostedZones.ToDictionary(x => x.Name), recordName); - if (hostedZone != null) - { - return hostedZone.Name; - } - _log.Error( - "Can't find hosted zone for {recordName} in resource group {ResourceGroupName}", - recordName, - _options.ResourceGroupName); - return null; - } - - /// <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(); - } -} +using Microsoft.Azure.Management.Dns;
+using Microsoft.Azure.Management.Dns.Models;
+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 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,
+ LookupClientProvider dnsClient,
+ ProxyService proxyService,
+ ILogService log,
+ ISettingsService settings) : base(dnsClient, log, settings)
+ {
+ _options = options;
+ _proxyService = proxyService;
+ _recordSets = new Dictionary<string, Dictionary<string, RecordSet>>();
+ _resourceManagerEndpoint = new Uri(AzureEnvironments.ResourceManagerUrls[AzureEnvironments.AzureCloud]);
+ if (!string.IsNullOrEmpty(options.AzureEnvironment))
+ {
+ if (!AzureEnvironments.ResourceManagerUrls.TryGetValue(options.AzureEnvironment, out var endpoint))
+ {
+ // Custom endpoint
+ endpoint = options.AzureEnvironment;
+ }
+ try
+ {
+ _resourceManagerEndpoint = new Uri(endpoint);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "Could not parse Azure endpoint url. Falling back to default.");
+ }
+ }
+ }
+
+ /// <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 zone = await GetHostedZone(record.Authority.Domain);
+ if (zone == null)
+ {
+ return false;
+ }
+ // Create or update record set parameters
+ var txtRecord = new TxtRecord(new[] { record.Value });
+ if (!_recordSets.ContainsKey(zone))
+ {
+ _recordSets.Add(zone, new Dictionary<string, RecordSet>());
+ }
+ var zoneRecords = _recordSets[zone];
+ var relativeKey = RelativeRecordName(zone, record.Authority.Domain);
+ if (!zoneRecords.ContainsKey(relativeKey))
+ {
+ zoneRecords.Add(
+ relativeKey,
+ new RecordSet
+ {
+ TTL = 0,
+ TxtRecords = new List<TxtRecord> { txtRecord }
+ });
+ }
+ else
+ {
+ zoneRecords[relativeKey].TxtRecords.Add(txtRecord);
+ }
+ return true;
+ }
+
+ /// <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)
+ {
+ foreach (var domain in _recordSets[zone].Keys)
+ {
+ updateTasks.Add(CreateOrUpdateRecordSet(zone, domain));
+ }
+ }
+ await Task.WhenAll(updateTasks);
+ }
+
+ /// <summary>
+ /// Store a single recordset
+ /// </summary>
+ /// <param name="zone"></param>
+ /// <param name="domain"></param>
+ /// <param name="recordSet"></param>
+ /// <returns></returns>
+ private async Task CreateOrUpdateRecordSet(string zone, string domain)
+ {
+ try
+ {
+ 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()
+ {
+ if (_azureDnsClient == null)
+ {
+ // Build the service credentials and DNS management client
+ ServiceClientCredentials credentials;
+
+ // Decide between Managed Service Identity (MSI)
+ // and service principal with client credentials
+ if (_options.UseMsi)
+ {
+ var azureServiceTokenProvider = new AzureServiceTokenProvider();
+ var accessToken = await azureServiceTokenProvider.GetAccessTokenAsync(_resourceManagerEndpoint.ToString());
+ credentials = new TokenCredentials(accessToken);
+ }
+ else
+ {
+ credentials = await ApplicationTokenProvider.LoginSilentAsync(
+ _options.TenantId,
+ _options.ClientId,
+ _options.Secret.Value,
+ GetActiveDirectorySettingsForAzureEnvironment());
+ }
+
+ _azureDnsClient = new DnsManagementClient(credentials, _proxyService.GetHttpClient(), true)
+ {
+ BaseUri = _resourceManagerEndpoint,
+ SubscriptionId = _options.SubscriptionId
+ };
+ }
+ return _azureDnsClient;
+ }
+
+ /// <summary>
+ /// Retrieve active directory settings based on the current Azure environment
+ /// </summary>
+ /// <returns></returns>
+ private ActiveDirectoryServiceSettings GetActiveDirectorySettingsForAzureEnvironment()
+ {
+ return _options.AzureEnvironment switch
+ {
+ AzureEnvironments.AzureChinaCloud => ActiveDirectoryServiceSettings.AzureChina,
+ AzureEnvironments.AzureUSGovernment => ActiveDirectoryServiceSettings.AzureUSGovernment,
+ AzureEnvironments.AzureGermanCloud => ActiveDirectoryServiceSettings.AzureGermany,
+ _ => ActiveDirectoryServiceSettings.Azure,
+ };
+ }
+
+ /// <summary>
+ /// Translate full host name to zone relative name
+ /// </summary>
+ /// <param name="zone"></param>
+ /// <param name="recordName"></param>
+ /// <returns></returns>
+ private string RelativeRecordName(string zone, string recordName)
+ {
+ var ret = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.');
+ return string.IsNullOrEmpty(ret) ? "@" : ret;
+ }
+
+ /// <summary>
+ /// Find the approriate hosting zone to use for record updates
+ /// </summary>
+ /// <param name="recordName"></param>
+ /// <returns></returns>
+ private async Task<string> GetHostedZone(string recordName)
+ {
+ // Cache so we don't have to repeat this more than once for each renewal
+ if (_hostedZones == null)
+ {
+ var client = await GetClient();
+ var zones = new List<Zone>();
+ var response = await client.Zones.ListByResourceGroupAsync(_options.ResourceGroupName);
+ zones.AddRange(response);
+ while (!string.IsNullOrEmpty(response.NextPageLink))
+ {
+ response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink);
+ }
+ _log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones.Count, _options.ResourceGroupName);
+ _hostedZones = zones;
+ }
+
+ var hostedZone = FindBestMatch(_hostedZones.ToDictionary(x => x.Name), recordName);
+ if (hostedZone != null)
+ {
+ return hostedZone.Name;
+ }
+ _log.Error(
+ "Can't find hosted zone for {recordName} in resource group {ResourceGroupName}",
+ recordName,
+ _options.ResourceGroupName);
+ return null;
+ }
+
+ /// <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/AzureArguments.cs b/src/plugin.validation.dns.azure/AzureArguments.cs index 9374976..502de4f 100755 --- a/src/plugin.validation.dns.azure/AzureArguments.cs +++ b/src/plugin.validation.dns.azure/AzureArguments.cs @@ -1,12 +1,13 @@ -namespace PKISharp.WACS.Plugins.ValidationPlugins -{ - public class AzureArguments - { - public bool AzureUseMsi { get; set; } - public string AzureTenantId { get; set; } - public string AzureClientId { get; set; } - public string AzureSecret { get; set; } - public string AzureSubscriptionId { get; set; } - public string AzureResourceGroupName { get; set; } - } +namespace PKISharp.WACS.Plugins.ValidationPlugins
+{
+ public class AzureArguments
+ {
+ public string AzureEnvironment { get; set; }
+ public bool AzureUseMsi { get; set; }
+ public string AzureTenantId { get; set; }
+ public string AzureClientId { get; set; }
+ public string AzureSecret { get; set; }
+ public string AzureSubscriptionId { get; set; }
+ public string AzureResourceGroupName { get; set; }
+ }
}
\ No newline at end of file diff --git a/src/plugin.validation.dns.azure/AzureArgumentsProvider.cs b/src/plugin.validation.dns.azure/AzureArgumentsProvider.cs index ce9d42b..9bd5544 100755 --- a/src/plugin.validation.dns.azure/AzureArgumentsProvider.cs +++ b/src/plugin.validation.dns.azure/AzureArgumentsProvider.cs @@ -1,33 +1,38 @@ -using Fclp; -using PKISharp.WACS.Configuration; - -namespace PKISharp.WACS.Plugins.ValidationPlugins -{ - public class AzureArgumentsProvider : BaseArgumentsProvider<AzureArguments> - { - public override string Name => "Azure"; - public override string Group => "Validation"; - public override string Condition => "--validationmode dns-01 --validation azure"; - public override void Configure(FluentCommandLineParser<AzureArguments> parser) - { - _ = parser.Setup(o => o.AzureUseMsi) - .As("azureusemsi") - .WithDescription("Use Managed Service Identity for authentication."); - _ = parser.Setup(o => o.AzureTenantId) - .As("azuretenantid") - .WithDescription("Tenant ID to login into Microsoft Azure."); - _ = parser.Setup(o => o.AzureClientId) - .As("azureclientid") - .WithDescription("Client ID to login into Microsoft Azure."); - _ = parser.Setup(o => o.AzureSecret) - .As("azuresecret") - .WithDescription("Secret to login into Microsoft Azure."); - _ = parser.Setup(o => o.AzureSubscriptionId) - .As("azuresubscriptionid") - .WithDescription("Subscription ID to login into Microsoft Azure DNS."); - _ = parser.Setup(o => o.AzureResourceGroupName) - .As("azureresourcegroupname") - .WithDescription("The name of the resource group within Microsoft Azure DNS."); - } - } -} +using Fclp;
+using PKISharp.WACS.Configuration;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins
+{
+ public class AzureArgumentsProvider : BaseArgumentsProvider<AzureArguments>
+ {
+ public override string Name => "Azure";
+ public override string Group => "Validation";
+ public override string Condition => "--validationmode dns-01 --validation azure";
+ public override void Configure(FluentCommandLineParser<AzureArguments> parser)
+ {
+ _ = parser.Setup(o => o.AzureEnvironment)
+ .As("azureenvironment")
+ .WithDescription("This can be used to specify a specific Azure endpoint. " +
+ "Valid inputs are AzureCloud (default), AzureChinaCloud, AzureGermanCloud, " +
+ "AzureUSGovernment or a specific URI for an Azure Stack implementation.");
+ _ = parser.Setup(o => o.AzureUseMsi)
+ .As("azureusemsi")
+ .WithDescription("Use Managed Service Identity for authentication.");
+ _ = parser.Setup(o => o.AzureTenantId)
+ .As("azuretenantid")
+ .WithDescription("Tenant ID to login into Microsoft Azure.");
+ _ = parser.Setup(o => o.AzureClientId)
+ .As("azureclientid")
+ .WithDescription("Client ID to login into Microsoft Azure.");
+ _ = parser.Setup(o => o.AzureSecret)
+ .As("azuresecret")
+ .WithDescription("Secret to login into Microsoft Azure.");
+ _ = parser.Setup(o => o.AzureSubscriptionId)
+ .As("azuresubscriptionid")
+ .WithDescription("Subscription ID to login into Microsoft Azure DNS.");
+ _ = parser.Setup(o => o.AzureResourceGroupName)
+ .As("azureresourcegroupname")
+ .WithDescription("The name of the resource group within Microsoft Azure DNS.");
+ }
+ }
+}
diff --git a/src/plugin.validation.dns.azure/AzureEnvironments.cs b/src/plugin.validation.dns.azure/AzureEnvironments.cs new file mode 100644 index 0000000..267d99c --- /dev/null +++ b/src/plugin.validation.dns.azure/AzureEnvironments.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins
+{
+ public static class AzureEnvironments
+ {
+ public const string AzureCloud = "AzureCloud";
+
+ public const string AzureChinaCloud = "AzureChinaCloud";
+
+ public const string AzureUSGovernment = "AzureUSGovernment";
+
+ public const string AzureGermanCloud = "AzureGermanCloud";
+
+ public static IDictionary<string, string> ResourceManagerUrls
+ = new ConcurrentDictionary<string, string>()
+ {
+ [AzureCloud] = "https://management.azure.com",
+ [AzureChinaCloud] = "https://management.chinacloudapi.cn",
+ [AzureUSGovernment] = "https://management.usgovcloudapi.net",
+ [AzureGermanCloud] = "https://management.microsoftazure.de",
+ };
+ }
+}
diff --git a/src/plugin.validation.dns.azure/AzureOptions.cs b/src/plugin.validation.dns.azure/AzureOptions.cs index a064f7a..b29d08b 100755 --- a/src/plugin.validation.dns.azure/AzureOptions.cs +++ b/src/plugin.validation.dns.azure/AzureOptions.cs @@ -1,25 +1,26 @@ -using Newtonsoft.Json; -using PKISharp.WACS.Plugins.Base; -using PKISharp.WACS.Plugins.Base.Options; -using PKISharp.WACS.Services.Serialization; - -namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns -{ - [Plugin("aa57b028-45fb-4aca-9cac-a63d94c76b4a")] - internal class AzureOptions : ValidationPluginOptions<Azure> - { - public override string Name => "Azure"; - public override string Description => "Create verification records in Azure DNS"; - public override string ChallengeType => Constants.Dns01ChallengeType; - - public bool UseMsi { get; set; } - public string ClientId { get; set; } - public string ResourceGroupName { get; set; } - - [JsonProperty(propertyName: "SecretSafe")] - public ProtectedString Secret { get; set; } - - public string SubscriptionId { get; set; } - public string TenantId { get; set; } - } -} +using Newtonsoft.Json;
+using PKISharp.WACS.Plugins.Base;
+using PKISharp.WACS.Plugins.Base.Options;
+using PKISharp.WACS.Services.Serialization;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns
+{
+ [Plugin("aa57b028-45fb-4aca-9cac-a63d94c76b4a")]
+ internal class AzureOptions : ValidationPluginOptions<Azure>
+ {
+ public override string Name => "Azure";
+ public override string Description => "Create verification records in Azure DNS";
+ public override string ChallengeType => Constants.Dns01ChallengeType;
+
+ public string AzureEnvironment { get; set; }
+ public bool UseMsi { get; set; }
+ public string ClientId { get; set; }
+ public string ResourceGroupName { get; set; }
+
+ [JsonProperty(propertyName: "SecretSafe")]
+ public ProtectedString Secret { get; set; }
+
+ public string SubscriptionId { get; set; }
+ public string TenantId { get; set; }
+ }
+}
diff --git a/src/plugin.validation.dns.azure/AzureOptionsFactory.cs b/src/plugin.validation.dns.azure/AzureOptionsFactory.cs index 7a67aa3..f89ce70 100755 --- a/src/plugin.validation.dns.azure/AzureOptionsFactory.cs +++ b/src/plugin.validation.dns.azure/AzureOptionsFactory.cs @@ -1,66 +1,114 @@ -using ACMESharp.Authorizations; -using PKISharp.WACS.DomainObjects; -using PKISharp.WACS.Plugins.Base.Factories; -using PKISharp.WACS.Services; -using PKISharp.WACS.Services.Serialization; -using System.Threading.Tasks; - -namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns -{ - /// <summary> - /// Azure DNS validation - /// </summary> - internal class AzureOptionsFactory : ValidationPluginOptionsFactory<Azure, AzureOptions> - { - private readonly IArgumentsService _arguments; - - public AzureOptionsFactory(IArgumentsService arguments) : base(Dns01ChallengeValidationDetails.Dns01ChallengeType) => _arguments = arguments; - - public override async Task<AzureOptions> Aquire(Target target, IInputService input, RunLevel runLevel) - { - var az = _arguments.GetArguments<AzureArguments>(); - - var useMsi = az.AzureUseMsi || await input.PromptYesNo("Do you want to use a managed service identity?", true); - var options = new AzureOptions - { - UseMsi = useMsi, - }; - - if (!useMsi) - { - // These options are only necessary for client id/secret authentication. - options.TenantId = await _arguments.TryGetArgument(az.AzureTenantId, input, "Directory/tenant id"); - options.ClientId = await _arguments.TryGetArgument(az.AzureClientId, input, "Application client id"); - options.Secret = new ProtectedString(await _arguments.TryGetArgument(az.AzureSecret, input,"Application client secret", true)); - } - - options.SubscriptionId = await _arguments.TryGetArgument(az.AzureSubscriptionId, input, "DNS subscription id"); - options.ResourceGroupName = await _arguments.TryGetArgument(az.AzureResourceGroupName, input, "DNS resource group name"); - - return options; - } - - public override Task<AzureOptions> Default(Target target) - { - var az = _arguments.GetArguments<AzureArguments>(); - var options = new AzureOptions - { - UseMsi = az.AzureUseMsi, - SubscriptionId = _arguments.TryGetRequiredArgument(nameof(az.AzureSubscriptionId), az.AzureSubscriptionId), - ResourceGroupName = _arguments.TryGetRequiredArgument(nameof(az.AzureResourceGroupName), az.AzureResourceGroupName) - }; - - if (!options.UseMsi) - { - // These options are only necessary for client id/secret authentication. - options.TenantId = _arguments.TryGetRequiredArgument(nameof(az.AzureTenantId), az.AzureTenantId); - options.ClientId = _arguments.TryGetRequiredArgument(nameof(az.AzureClientId), az.AzureClientId); - options.Secret = new ProtectedString(_arguments.TryGetRequiredArgument(nameof(az.AzureSecret), az.AzureSecret)); - } - - return Task.FromResult(options); - } - - public override bool CanValidate(Target target) => true; - } -} +using ACMESharp.Authorizations;
+using PKISharp.WACS.DomainObjects;
+using PKISharp.WACS.Plugins.Base.Factories;
+using PKISharp.WACS.Services;
+using PKISharp.WACS.Services.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns
+{
+ /// <summary>
+ /// Azure DNS validation
+ /// </summary>
+ internal class AzureOptionsFactory : ValidationPluginOptionsFactory<Azure, AzureOptions>
+ {
+ private readonly IArgumentsService _arguments;
+
+ public AzureOptionsFactory(IArgumentsService arguments) : base(Dns01ChallengeValidationDetails.Dns01ChallengeType) => _arguments = arguments;
+
+ public override async Task<AzureOptions> Aquire(Target target, IInputService input, RunLevel runLevel)
+ {
+ var az = _arguments.GetArguments<AzureArguments>();
+
+ var options = new AzureOptions();
+
+ var environments = new List<Choice<Func<Task>>>(
+ AzureEnvironments.ResourceManagerUrls
+ .OrderBy(kvp => kvp.Key)
+ .Select(kvp =>
+ Choice.Create<Func<Task>>(() =>
+ {
+ options.AzureEnvironment = kvp.Key;
+ return Task.CompletedTask;
+ },
+ description: kvp.Key,
+ @default: kvp.Key == AzureEnvironments.AzureCloud)))
+ {
+ Choice.Create<Func<Task>>(async () => await InputUrl(input, options), "Use a custom resource manager url")
+ };
+
+ var chosen = await input.ChooseFromMenu("Which Azure environment are you using?", environments);
+ await chosen.Invoke();
+
+ options.UseMsi = az.AzureUseMsi || await input.PromptYesNo("Do you want to use a managed service identity?", true);
+
+ if (!options.UseMsi)
+ {
+ // These options are only necessary for client id/secret authentication.
+ options.TenantId = await _arguments.TryGetArgument(az.AzureTenantId, input, "Directory/tenant id");
+ options.ClientId = await _arguments.TryGetArgument(az.AzureClientId, input, "Application client id");
+ options.Secret = new ProtectedString(await _arguments.TryGetArgument(az.AzureSecret, input,"Application client secret", true));
+ }
+
+ options.SubscriptionId = await _arguments.TryGetArgument(az.AzureSubscriptionId, input, "DNS subscription id");
+ options.ResourceGroupName = await _arguments.TryGetArgument(az.AzureResourceGroupName, input, "DNS resource group name");
+
+ return options;
+ }
+
+ public override Task<AzureOptions> Default(Target target)
+ {
+ var az = _arguments.GetArguments<AzureArguments>();
+ var options = new AzureOptions
+ {
+ UseMsi = az.AzureUseMsi,
+ AzureEnvironment = az.AzureEnvironment,
+ SubscriptionId = _arguments.TryGetRequiredArgument(nameof(az.AzureSubscriptionId), az.AzureSubscriptionId),
+ ResourceGroupName = _arguments.TryGetRequiredArgument(nameof(az.AzureResourceGroupName), az.AzureResourceGroupName)
+ };
+
+ if (!options.UseMsi)
+ {
+ // These options are only necessary for client id/secret authentication.
+ options.TenantId = _arguments.TryGetRequiredArgument(nameof(az.AzureTenantId), az.AzureTenantId);
+ options.ClientId = _arguments.TryGetRequiredArgument(nameof(az.AzureClientId), az.AzureClientId);
+ options.Secret = new ProtectedString(_arguments.TryGetRequiredArgument(nameof(az.AzureSecret), az.AzureSecret));
+ }
+
+ return Task.FromResult(options);
+ }
+
+ public override bool CanValidate(Target target) => true;
+
+ private async Task InputUrl(IInputService input, AzureOptions options)
+ {
+ string raw;
+ do
+ {
+ raw = await input.RequestString("Url");
+ }
+ while (!ParseUrl(raw, options));
+ }
+
+ private bool ParseUrl(string url, AzureOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ return false;
+ }
+ try
+ {
+ var uri = new Uri(url);
+ options.AzureEnvironment = uri.ToString();
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+ }
+}
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 b932057..9a58aa7 100755 --- a/src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj +++ b/src/plugin.validation.dns.azure/wacs.validation.dns.azure.csproj @@ -9,7 +9,7 @@ <ItemGroup> <PackageReference Include="Microsoft.Azure.Management.Dns" Version="3.0.1" /> <PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.5.0" /> - <PackageReference Include="Microsoft.Rest.ClientRuntime.Azure.Authentication" Version="2.4.0" /> + <PackageReference Include="Microsoft.Rest.ClientRuntime.Azure.Authentication" Version="2.4.1" /> </ItemGroup> <ItemGroup> diff --git a/src/plugin.validation.dns.cloudflare/wacs.validation.dns.cloudflare.csproj b/src/plugin.validation.dns.cloudflare/wacs.validation.dns.cloudflare.csproj index 0ea9d8c..3f04238 100644 --- a/src/plugin.validation.dns.cloudflare/wacs.validation.dns.cloudflare.csproj +++ b/src/plugin.validation.dns.cloudflare/wacs.validation.dns.cloudflare.csproj @@ -15,7 +15,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> </ItemGroup> </Project> diff --git a/src/plugin.validation.dns.digitalocean/DigitalOcean.cs b/src/plugin.validation.dns.digitalocean/DigitalOcean.cs new file mode 100644 index 0000000..f25ff9d --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/DigitalOcean.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using DigitalOcean.API; +using DigitalOcean.API.Models.Requests; +using PKISharp.WACS.Clients.DNS; +using PKISharp.WACS.Services; + +namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns +{ + internal class DigitalOcean : DnsValidation<DigitalOcean> + { + private readonly IDigitalOceanClient _doClient; + private long? _recordId; + + public DigitalOcean(DigitalOceanOptions options, LookupClientProvider dnsClient, ILogService log, ISettingsService settings) : base(dnsClient, log, settings) + { + _doClient = new DigitalOceanClient(options.ApiToken.Value); + } + + public override async Task DeleteRecord(DnsValidationRecord record) + { + try + { + var (_, zone) = SplitDomain(record.Authority.Domain); + if (_recordId == null) + { + _log.Warning("Not deleting DNS records on DigitalOcean because of missing record id."); + return; + } + + await _doClient.DomainRecords.Delete(zone, _recordId.Value); + _log.Information("Successfully deleted DNS record on DigitalOcean."); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to delete DNS record on DigitalOcean."); + } + } + + public override async Task<bool> CreateRecord(DnsValidationRecord record) + { + try + { + var (name, zone) = SplitDomain(record.Authority.Domain); + var createdRecord = await _doClient.DomainRecords.Create(zone, new DomainRecord + { + Type = "TXT", + Name = name, + Data = record.Value, + Ttl = 300 + }); + _recordId = createdRecord.Id; + return true; + } + catch (Exception ex) + { + _log.Error(ex, "Failed to create DNS record on DigitalOcean."); + return false; + } + } + + private (string, string) SplitDomain(string domain) + { + var index = domain.IndexOf('.'); + return (domain.Substring(0, index), domain.Substring(index + 1)); + } + } +} diff --git a/src/plugin.validation.dns.digitalocean/DigitalOceanArguments.cs b/src/plugin.validation.dns.digitalocean/DigitalOceanArguments.cs new file mode 100644 index 0000000..072d0d8 --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/DigitalOceanArguments.cs @@ -0,0 +1,7 @@ +namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns +{ + public class DigitalOceanArguments + { + public string ApiToken { get; set; } + } +}
\ No newline at end of file diff --git a/src/plugin.validation.dns.digitalocean/DigitalOceanArgumentsProvider.cs b/src/plugin.validation.dns.digitalocean/DigitalOceanArgumentsProvider.cs new file mode 100644 index 0000000..394021c --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/DigitalOceanArgumentsProvider.cs @@ -0,0 +1,18 @@ +using Fclp; +using PKISharp.WACS.Configuration; + +namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns +{ + public class DigitalOceanArgumentsProvider : BaseArgumentsProvider<DigitalOceanArguments> + { + public override string Name => "DigitalOcean"; + public override string Group => "Validation"; + public override string Condition => "--validationmode dns-01 --validation digitalocean"; + public override void Configure(FluentCommandLineParser<DigitalOceanArguments> parser) + { + _ = parser.Setup(o => o.ApiToken) + .As("digitaloceanapitoken") + .WithDescription("The API token to authenticate against the DigitalOcean API"); + } + } +}
\ No newline at end of file diff --git a/src/plugin.validation.dns.digitalocean/DigitalOceanOptions.cs b/src/plugin.validation.dns.digitalocean/DigitalOceanOptions.cs new file mode 100644 index 0000000..19cba1e --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/DigitalOceanOptions.cs @@ -0,0 +1,15 @@ +using PKISharp.WACS.Plugins.Base; +using PKISharp.WACS.Plugins.Base.Options; +using PKISharp.WACS.Services.Serialization; + +namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns +{ + [Plugin("1a87d670-3fa3-4a2a-bb10-491d48feb5db")] + internal class DigitalOceanOptions : ValidationPluginOptions<DigitalOcean> + { + public override string Name => "DigitalOcean"; + public override string Description => "Create verification records on DigitalOcean"; + public override string ChallengeType => Constants.Dns01ChallengeType; + public ProtectedString ApiToken { get; set; } + } +}
\ No newline at end of file diff --git a/src/plugin.validation.dns.digitalocean/DigitalOceanOptionsFactory.cs b/src/plugin.validation.dns.digitalocean/DigitalOceanOptionsFactory.cs new file mode 100644 index 0000000..d52860e --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/DigitalOceanOptionsFactory.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using ACMESharp.Authorizations; +using PKISharp.WACS.DomainObjects; +using PKISharp.WACS.Plugins.Base.Factories; +using PKISharp.WACS.Services; +using PKISharp.WACS.Services.Serialization; + +namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns +{ + internal class DigitalOceanOptionsFactory : ValidationPluginOptionsFactory<DigitalOcean, DigitalOceanOptions> + { + private readonly IArgumentsService _arguments; + + public DigitalOceanOptionsFactory(IArgumentsService arguments) : base(Dns01ChallengeValidationDetails.Dns01ChallengeType) + { + _arguments = arguments; + } + + public override Task<DigitalOceanOptions> Aquire(Target target, IInputService inputService, RunLevel runLevel) + { + var arguments = _arguments.GetArguments<DigitalOceanArguments>(); + return Task.FromResult(new DigitalOceanOptions + { + ApiToken = new ProtectedString(arguments.ApiToken) + }); + } + + public override Task<DigitalOceanOptions> Default(Target target) + { + var arguments = _arguments.GetArguments<DigitalOceanArguments>(); + return Task.FromResult(new DigitalOceanOptions + { + ApiToken = new ProtectedString( + _arguments.TryGetRequiredArgument(nameof(arguments.ApiToken), arguments.ApiToken)) + }); + } + + public override bool CanValidate(Target target) => true; + } +}
\ No newline at end of file diff --git a/src/plugin.validation.dns.digitalocean/wacs.validation.dns.digitalocean.csproj b/src/plugin.validation.dns.digitalocean/wacs.validation.dns.digitalocean.csproj new file mode 100644 index 0000000..5a4ec0b --- /dev/null +++ b/src/plugin.validation.dns.digitalocean/wacs.validation.dns.digitalocean.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netcoreapp3.1</TargetFramework> + <RootNamespace>PKISharp.WACS.Plugins.ValidationPlugins.Dns</RootNamespace> + <AssemblyName>PKISharp.WACS.Plugins.ValidationPlugins.DigitalOcean</AssemblyName> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="DigitalOcean.API" Version="5.1.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\main.lib\wacs.lib.csproj" /> + </ItemGroup> + +</Project> 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 4640fb5..59abcbb 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.104.12" /> + <PackageReference Include="AWSSDK.Route53" Version="3.3.106.16" /> </ItemGroup> <ItemGroup> diff --git a/src/wacs.sln b/src/wacs.sln index fe889a8..d30363f 100644 --- a/src/wacs.sln +++ b/src/wacs.sln @@ -38,6 +38,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wacs.validation.dns.cloudfl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wacs.validation.dns.luadns", "plugin.validation.dns.luadns\wacs.validation.dns.luadns.csproj", "{424630AC-3029-4188-B78A-A630316A4B99}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "wacs.validation.dns.digitalocean", "plugin.validation.dns.digitalocean\wacs.validation.dns.digitalocean.csproj", "{D8BD50E6-0759-476B-A021-0CF1325B4DDB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -105,6 +107,12 @@ Global {424630AC-3029-4188-B78A-A630316A4B99}.Release|Any CPU.Build.0 = Release|Any CPU {424630AC-3029-4188-B78A-A630316A4B99}.ReleasePluggable|Any CPU.ActiveCfg = Release|Any CPU {424630AC-3029-4188-B78A-A630316A4B99}.ReleasePluggable|Any CPU.Build.0 = Release|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.Release|Any CPU.Build.0 = Release|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.ReleasePluggable|Any CPU.ActiveCfg = Release|Any CPU + {D8BD50E6-0759-476B-A021-0CF1325B4DDB}.ReleasePluggable|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE |