Skip to content

Commit

Permalink
Fix concurrency issues
Browse files Browse the repository at this point in the history
ACMELib wasn't properly handling when the list of available nonces had drained.
While it was requesting a new one, the new one was never attached to the request.

Now that ACMELib handles that correctly, rewrite CloudflareIntegration example to validate each domain in a concurrent manor.
  • Loading branch information
Kencdk committed Jun 30, 2024
1 parent 23b3d07 commit e038fa7
Show file tree
Hide file tree
Showing 20 changed files with 189 additions and 205 deletions.
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
-->
<PropertyGroup>
<FileAlignment>512</FileAlignment>
<LangVersion>11.0</LangVersion>
<LangVersion>12.0</LangVersion>
</PropertyGroup>

<!-- include Ken Christensen in all assemblies -->
<PropertyGroup>
<Company>Ken Christensen</Company>
<Product>Kenc.ACMELib</Product>
<Authors>Ken Christensen</Authors>
<Copyright2022 Ken Christensen. All rights reserved.</Copyright>
<Copyright2024 Ken Christensen. All rights reserved.</Copyright>
</PropertyGroup>

<!-- Saving some paths that are used elsewhere in MSBuild settings and targets -->
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "7.0.0",
"version": "8.0.0",
"rollForward": "latestFeature",
"allowPrerelease": false
}
Expand Down
7 changes: 7 additions & 0 deletions src/ACME.sln
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kenc.ACMELibCore.Tests", "L
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudflareIntegration", "Examples\CloudflareIntegration\CloudflareIntegration.csproj", "{79B0EA59-9B39-4E97-8080-012D91BCA16B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Examples\Shared\Shared.csproj", "{887A6A88-2865-498E-BF8C-52B194295553}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -35,13 +37,18 @@ Global
{79B0EA59-9B39-4E97-8080-012D91BCA16B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79B0EA59-9B39-4E97-8080-012D91BCA16B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{79B0EA59-9B39-4E97-8080-012D91BCA16B}.Release|Any CPU.Build.0 = Release|Any CPU
{887A6A88-2865-498E-BF8C-52B194295553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{887A6A88-2865-498E-BF8C-52B194295553}.Debug|Any CPU.Build.0 = Debug|Any CPU
{887A6A88-2865-498E-BF8C-52B194295553}.Release|Any CPU.ActiveCfg = Release|Any CPU
{887A6A88-2865-498E-BF8C-52B194295553}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BD4872DB-73DF-497D-915D-C20EB4283167} = {C9A9AB54-D122-48CC-9CA7-F34BC5DB3293}
{79B0EA59-9B39-4E97-8080-012D91BCA16B} = {C9A9AB54-D122-48CC-9CA7-F34BC5DB3293}
{887A6A88-2865-498E-BF8C-52B194295553} = {C9A9AB54-D122-48CC-9CA7-F34BC5DB3293}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A7972CCF-1FB8-4419-B115-B60882F8FCF0}
Expand Down
2 changes: 1 addition & 1 deletion src/Examples/ACMEClient/ACMEClient.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
Expand All @@ -8,6 +7,7 @@

<ItemGroup>
<ProjectReference Include="..\..\Libraries\ACMELib\Kenc.ACMELib.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>

</Project>
17 changes: 7 additions & 10 deletions src/Examples/ACMEClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Kenc.ACMELib;
using Kenc.ACMELib.ACMEObjects;
using Kenc.ACMELib.ACMEResponses;
using Kenc.ACMELib.Examples.Shared;
using Kenc.ACMELib.Exceptions;
using Kenc.ACMELib.Exceptions.API;

Expand Down Expand Up @@ -87,7 +88,7 @@ private static async Task Main()
var userContact = Console.ReadLine();
try
{
account = await acmeClient.RegisterAsync(new[] { "mailto:" + userContact });
account = await acmeClient.RegisterAsync(["mailto:" + userContact]);
}
catch (Exception ex)
{
Expand All @@ -112,8 +113,8 @@ private static async Task Main()
var certificateFiles = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.crt").ToList();
Console.WriteLine($"Revoking certificates: {string.Join(',', certificateFiles)}");

var foo = HandleConsoleInput("Continue?", new[] { "y", "yes", "n", "no" }, false).ToLower();
if (foo == "y" || foo == "yes")
var foo = HandleConsoleInput("Continue?", ["y", "yes", "n", "no"], false).ToLower();
if (foo is "y" or "yes")
{
IEnumerable<X509Certificate> certificates = certificateFiles.Select(path => X509Certificate2.CreateFromCertFile(path));
foreach (X509Certificate certificate in certificates)
Expand Down Expand Up @@ -199,8 +200,8 @@ private static async Task OrderDomains(ACMEClient acmeClient, params string[] do
Console.WriteLine($"Unknown challenge type encountered '{challenge.Type}'. Please handle accourdingly.");
}

var result = HandleConsoleInput("Challenge completed? [y/n]", new[] { "y", "yes", "n", "no" });
if (result == "y" || result == "yes")
var result = HandleConsoleInput("Challenge completed? [y/n]", ["y", "yes", "n", "no"]);
if (result is "y" or "yes")
{
Console.WriteLine("Validating challenge");
var validation = await ValidateChallengeCompletion(challenge, item.Identifier.Value);
Expand Down Expand Up @@ -362,12 +363,8 @@ private static string HandleConsoleInput(string prompt, string[] acceptedRespons
{
Console.WriteLine(prompt);
var input = Console.ReadLine();
if (!caseSensitive)
{
input = input.ToLowerInvariant();
}

if (acceptedResponses.Contains(input))
if (acceptedResponses.Contains(input, caseSensitive ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal))
{
return input;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.0.79" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

</PropertyGroup>

<ItemGroup>
Expand All @@ -15,6 +15,6 @@

<ItemGroup>
<ProjectReference Include="..\..\Libraries\ACMELib\Kenc.ACMELib.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>

</Project>
121 changes: 76 additions & 45 deletions src/Examples/CloudflareIntegration/OrderDomains.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Kenc.ACMELib;
using Kenc.ACMELib.ACMEObjects;
using Kenc.ACMELib.ACMEResponses;
using Kenc.ACMELib.Examples.Shared;
using Kenc.ACMELib.Exceptions.API;
using Kenc.Cloudflare.Core;
using Kenc.Cloudflare.Core.Clients;
Expand Down Expand Up @@ -63,6 +64,17 @@ public OrderDomains(Options options)
var exportKey = rsaCryptoServiceProvider.ExportCspBlob(true);
var strKey = Convert.ToBase64String(exportKey);

if (File.Exists("acmekey.key"))
{

var result = ConsoleInput.Prompt("WARNING - acmekey.key already exists - do you wish to overwrite?", ["y", "yes", "n", "no"]);
if (!ConsoleInput.PositivePromptAnswers.Contains(result, StringComparer.OrdinalIgnoreCase))
{
// user doesn't wish to overwrite the key. Abort
throw new Exception("User aborted");
}
}

File.WriteAllText("acmekey.key", strKey);
}
else
Expand Down Expand Up @@ -139,7 +151,7 @@ public async Task ValidateACMEConnection()
var userContact = Console.ReadLine();
try
{
account = await acmeClient.RegisterAsync(new[] { "mailto:" + userContact });
account = await acmeClient.RegisterAsync(["mailto:" + userContact]);
}
catch (Exception ex)
{
Expand All @@ -161,76 +173,91 @@ public async Task<Order> ValidateDomains()
AuthorizationChallengeResponse[] challengeResponses = await Task.WhenAll(preAuthorizationChallenges);
}

IEnumerable<OrderIdentifier> domains = options.Domains.Select(domain => new OrderIdentifier { Type = ChallengeType.DNSChallenge, Value = domain });
var domains = options.Domains.Select(domain => new OrderIdentifier { Type = ChallengeType.DNSChallenge, Value = domain }).ToList();
Order order = await NewOrderAsync(acmeClient, domains);

// todo: save order identifier
Uri location = order.Location;
Program.LogLine($"Order location: {order.Location}");


// get challenge results.
Uri[] validations = order.Authorizations;
var dnsRecords = new List<string>(order.Authorizations.Length);
IEnumerable<AuthorizationChallengeResponse> auths = await RetrieveAuthz(acmeClient, validations);
foreach (AuthorizationChallengeResponse item in auths)
{
Program.LogLine($"Processing validations for {item.Identifier.Value}");
List<AuthorizationChallengeResponse> auths = await RetrieveAuthz(acmeClient, validations);

// now that we have all the authz responses - add challenges for those that needs it.
Task<(AuthorizationChallenge dnsChallenge, string Value)>[] dnsChallenges = auths.Select(async item =>
{
if (item.Status == ACMEStatus.Valid)
{
Program.LogLine("Domain already validated succesfully.");
continue;
Program.LogLine($"{item.Identifier} already validated succesfully.");
return (null, null);
}

AuthorizationChallenge validChallenge = item.Challenges.Where(challenge => challenge.Status == ACMEStatus.Valid).FirstOrDefault();
if (validChallenge != null)
{
Program.LogLine("Found a valid challenge, skipping domain.", true);
Program.LogLine(validChallenge.Type, true);
continue;
return (null, null);
}

// limit to DNS challenges, as we can handle them with Cloudflare.
AuthorizationChallenge dnsChallenge = item.Challenges.FirstOrDefault(x => x.Type == "dns-01");
Program.LogLine($"Got challenge for {item.Identifier.Value}");
Program.LogLine($"Adding DNS entry for {item.Identifier.Value}");
var recordId = await AddDNSEntry(item.Identifier.Value, dnsChallenge.AuthorizationToken);
dnsRecords.Add(recordId);

// validate the DNS record is accessible.
await ValidateChallengeCompletion(dnsChallenge, item.Identifier.Value);
AuthorizationChallengeResponse c = await CompleteChallenge(acmeClient, dnsChallenge);
return (dnsChallenge, item.Identifier.Value);
}).ToArray();

while (c.Status == ACMEStatus.Pending)
{
await Task.Delay(5000);
c = await acmeClient.GetAuthorizationChallengeAsync(dnsChallenge.Url);
}
(AuthorizationChallenge dnsChallenge, string Domain)[] challenges = (await Task.WhenAll(dnsChallenges))
.Where(x => x.dnsChallenge is not null).ToArray();

if (c.Status == ACMEStatus.Valid)
{
// no reason to keep going, we have one succesfull challenge!
continue;
}
}
// foreach of the challenges - let's check if we can resolve them with DNS.
Task[] completionTasks = challenges.Select(async x =>
{
await ValidateChallengeCompletion(x.dnsChallenge, x.Domain);
await CompleteChallenge(acmeClient, x.dnsChallenge);
}).ToArray();
await Task.WhenAll(completionTasks);

var failedAuthorizations = new List<string>();
foreach (Uri challenge in order.Authorizations)
// now that we have validated that all DNS records exists AND completed the challenges - let's check if they completed succesfully
Task<AuthorizationChallengeResponse>[] authorizationTasks = order.Authorizations.Select(async challenge =>
{
AuthorizationChallengeResponse c;
do
{
await Task.Delay(5000);
c = await acmeClient.GetAuthorizationChallengeAsync(challenge);

}
while (c == null || c.Status == ACMEStatus.Pending);

if (c.Status == ACMEStatus.Invalid)
return c;
}).ToArray();

AuthorizationChallengeResponse[] authorizationResults = await Task.WhenAll(authorizationTasks);
IEnumerable<IGrouping<ACMEStatus, AuthorizationChallengeResponse>> groupedByStatus = authorizationResults.GroupBy(x => x.Status);
foreach (IGrouping<ACMEStatus, AuthorizationChallengeResponse> group in groupedByStatus)
{
Console.WriteLine(group.Key);
foreach (AuthorizationChallengeResponse item in group)
{
failedAuthorizations.Add(c.Identifier.Value);
Console.WriteLine(item.Identifier.Value);
}
}

IEnumerable<IGrouping<ACMEStatus, AuthorizationChallengeResponse>> failedAuthorizations = groupedByStatus.Where(x => x.Key == ACMEStatus.Invalid);
if (failedAuthorizations.Any())
{
throw new Exception($"Failed to authorize the following domains {string.Join(',', failedAuthorizations)}.");
};

if (order.Location == null)
{
// sometimes, ACME doesn't respond with the location -.-
order.Location = location;
}

return order;
Expand Down Expand Up @@ -366,22 +393,10 @@ private static async Task<AuthorizationChallengeResponse> NewAuthorizationAsync(
return await acmeClient.NewAuthorizationAsync(domain);
}

private static async Task<IEnumerable<AuthorizationChallengeResponse>> RetrieveAuthz(ACMEClient acmeClient, Uri[] uris)
private static async Task<List<AuthorizationChallengeResponse>> RetrieveAuthz(ACMEClient acmeClient, Uri[] uris)
{
var challenges = new List<AuthorizationChallengeResponse>();
foreach (Uri uri in uris)
{
try
{
AuthorizationChallengeResponse result = await acmeClient.GetAuthorizationChallengeAsync(uri);
challenges.Add(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
return challenges;
var tasks = uris.Select(x => acmeClient.GetAuthorizationChallengeAsync(x)).ToList();
return [.. (await Task.WhenAll(tasks))];
}

private static async Task<AuthorizationChallengeResponse> CompleteChallenge(ACMEClient acmeClient, AuthorizationChallenge challenge)
Expand All @@ -408,6 +423,11 @@ private async Task<string> AddDNSEntry(string domain, string value)
Program.LogLine($"The DNS entry already exists. Ignoring {domain}/{value}");
return string.Empty;
}
catch (CloudflareException existsAlreadyException) when (existsAlreadyException.Errors[0].Code == "81058")
{
// record already exists.
return string.Empty;
}
}

private static async Task ValidateChallengeCompletion(AuthorizationChallenge challenge, string domainName)
Expand All @@ -423,7 +443,18 @@ private static async Task ValidateChallengeCompletion(AuthorizationChallenge cha
for (var i = 0; i < maxRetries; i++)
{
var lookup = new LookupClient(IPAddress.Parse("1.1.1.1"));
IDnsQueryResponse result = await lookup.QueryAsync($"_acme-challenge.{domainName}", QueryType.TXT);
IDnsQueryResponse result;
try
{
result = await lookup.QueryAsync($"_acme-challenge.{domainName}", QueryType.TXT);

}
catch (DnsResponseException dnsResponseException)
{
Program.LogLine($"Exception while querying DNS: {dnsResponseException.Message}");
continue;
}

TxtRecord record = result.Answers.TxtRecords().Where(txt => txt.Text.Contains(challenge.AuthorizationToken)).FirstOrDefault();
if (record != null)
{
Expand Down
Loading

0 comments on commit e038fa7

Please sign in to comment.