Skip to content

Commit

Permalink
Modified to retain DNS Provider name when certificates are issued (#623)
Browse files Browse the repository at this point in the history
* Introduce DNS Provider Name for any APIs

* Implemented using specify DNS provider for renew

* Fixed conflict resolution

* Improvement dns zone dropdown list

* Update packages
  • Loading branch information
shibayan authored Oct 23, 2023
1 parent c167a22 commit 756a267
Show file tree
Hide file tree
Showing 13 changed files with 193 additions and 85 deletions.
6 changes: 4 additions & 2 deletions KeyVault.Acmebot/Functions/GetDnsZones.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

using DurableTask.TypedProxy;

using KeyVault.Acmebot.Models;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
Expand All @@ -23,11 +25,11 @@ public GetDnsZones(IHttpContextAccessor httpContextAccessor)
}

[FunctionName($"{nameof(GetDnsZones)}_{nameof(Orchestrator)}")]
public Task<IReadOnlyList<string>> Orchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
public Task<IReadOnlyList<DnsZoneItem>> Orchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
var activity = context.CreateActivityProxy<ISharedActivity>();

return activity.GetZones();
return activity.GetAllDnsZones();
}

[FunctionName($"{nameof(GetDnsZones)}_{nameof(HttpStart)}")]
Expand Down
8 changes: 4 additions & 4 deletions KeyVault.Acmebot/Functions/ISharedActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ public interface ISharedActivity

Task<IReadOnlyList<CertificateItem>> GetAllCertificates(object input = null);

Task<IReadOnlyList<string>> GetZones(object input = null);
Task<IReadOnlyList<DnsZoneItem>> GetAllDnsZones(object input = null);

Task<CertificatePolicyItem> GetCertificatePolicy(string certificateName);

Task RevokeCertificate(string certificateName);

Task<OrderDetails> Order(IReadOnlyList<string> dnsNames);

Task Dns01Precondition(IReadOnlyList<string> dnsNames);
Task<string> Dns01Precondition((string, IReadOnlyList<string>) input);

Task<(IReadOnlyList<AcmeChallengeResult>, int)> Dns01Authorization(IReadOnlyList<string> authorizationUrls);
Task<(IReadOnlyList<AcmeChallengeResult>, int)> Dns01Authorization((string, IReadOnlyList<string>) input);

[RetryOptions("00:00:10", 12, HandlerType = typeof(ExceptionRetryStrategy<RetriableActivityException>))]
Task CheckDnsChallenge(IReadOnlyList<AcmeChallengeResult> challengeResults);
Expand All @@ -43,7 +43,7 @@ public interface ISharedActivity

Task<CertificateItem> MergeCertificate((string, OrderDetails) input);

Task CleanupDnsChallenge(IReadOnlyList<AcmeChallengeResult> challengeResults);
Task CleanupDnsChallenge((string, IReadOnlyList<AcmeChallengeResult>) challengeResults);

Task SendCompletedEvent((string, DateTimeOffset?, IReadOnlyList<string>) input);
}
60 changes: 45 additions & 15 deletions KeyVault.Acmebot/Functions/SharedActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,27 +95,27 @@ public async Task<IReadOnlyList<CertificateItem>> GetAllCertificates([ActivityTr
return result;
}

[FunctionName(nameof(GetZones))]
public async Task<IReadOnlyList<string>> GetZones([ActivityTrigger] object input = null)
[FunctionName(nameof(GetAllDnsZones))]
public async Task<IReadOnlyList<DnsZoneItem>> GetAllDnsZones([ActivityTrigger] object input = null)
{
try
{
var zones = await _dnsProviders.ListAllZonesAsync();

return zones.OrderBy(x => x.DnsProvider.Name).Select(x => x.Name).ToArray();
return zones.OrderBy(x => x.DnsProvider.Name).Select(x => x.ToDnsZoneItem()).ToArray();
}
catch
{
return Array.Empty<string>();
return Array.Empty<DnsZoneItem>();
}
}

[FunctionName(nameof(GetCertificatePolicy))]
public async Task<CertificatePolicyItem> GetCertificatePolicy([ActivityTrigger] string certificateName)
{
CertificatePolicy certificatePolicy = await _certificateClient.GetCertificatePolicyAsync(certificateName);
KeyVaultCertificateWithPolicy certificate = await _certificateClient.GetCertificateAsync(certificateName);

return certificatePolicy.ToCertificatePolicyItem(certificateName);
return certificate.ToCertificatePolicyItem();
}

[FunctionName(nameof(RevokeCertificate))]
Expand All @@ -137,11 +137,14 @@ public async Task<OrderDetails> Order([ActivityTrigger] IReadOnlyList<string> dn
}

[FunctionName(nameof(Dns01Precondition))]
public async Task Dns01Precondition([ActivityTrigger] IReadOnlyList<string> dnsNames)
public async Task<string> Dns01Precondition([ActivityTrigger] (string, IReadOnlyList<string>) input)
{
// DNS zone が存在するか確認
var (dnsProviderName, dnsNames) = input;

// DNS zone の一覧を各 Provider から取得
var zones = await _dnsProviders.ListAllZonesAsync();

// DNS zone が存在するか確認
var foundZones = new HashSet<DnsZone>();
var notFoundZoneDnsNames = new List<string>();

Expand Down Expand Up @@ -186,11 +189,35 @@ public async Task Dns01Precondition([ActivityTrigger] IReadOnlyList<string> dnsN
throw new PreconditionException($"The delegated name server is not correct. DNS zone = {zone.Name}, Expected = {string.Join(",", expectedNameServers)}, Actual = {string.Join(",", actualNameServers)}");
}
}

// 指定された DNS Provider に属する DNS zone を優先する
var dnsProvider = foundZones.Select(x => x.DnsProvider).FirstOrDefault(x => x.Name == dnsProviderName);

// DNS zone の属する Provider が変わった可能性があるのでフォールバック
if (dnsProvider is null)
{
// 見つかった DNS zone の属する DNS Provider を取得
var dnsProviders = foundZones.Select(x => x.DnsProvider).DistinctBy(x => x.Name).ToArray();

// 単一の DNS Provider で構成された証明書かチェックする
if (dnsProviders.Length != 1)
{
// 互換性のために常に空文字列を返す
return "";
}

// 単一の DNS Provider で構成されている場合は問題ない
dnsProvider = dnsProviders.First();
}

return dnsProvider.Name;
}

[FunctionName(nameof(Dns01Authorization))]
public async Task<(IReadOnlyList<AcmeChallengeResult>, int)> Dns01Authorization([ActivityTrigger] IReadOnlyList<string> authorizationUrls)
public async Task<(IReadOnlyList<AcmeChallengeResult>, int)> Dns01Authorization([ActivityTrigger] (string, IReadOnlyList<string>) input)
{
var (dnsProviderName, authorizationUrls) = input;

var acmeProtocolClient = await _acmeProtocolClientFactory.CreateClientAsync();

var challengeResults = new List<AcmeChallengeResult>();
Expand Down Expand Up @@ -219,8 +246,8 @@ public async Task Dns01Precondition([ActivityTrigger] IReadOnlyList<string> dnsN
});
}

// DNS zone の一覧を取得する
var zones = await _dnsProviders.ListAllZonesAsync();
// DNS zone の一覧を各 Provider から取得
var zones = await (string.IsNullOrEmpty(dnsProviderName) ? _dnsProviders.ListAllZonesAsync() : _dnsProviders.ListZonesAsync(dnsProviderName));

var propagationSeconds = 0;

Expand Down Expand Up @@ -355,7 +382,8 @@ public async Task<OrderDetails> FinalizeOrder([ActivityTrigger] (CertificatePoli
var certificateOperation = await _certificateClient.StartCreateCertificateAsync(certificatePolicyItem.CertificateName, certificatePolicy, tags: new Dictionary<string, string>
{
{ "Issuer", "Acmebot" },
{ "Endpoint", _options.Endpoint.Host }
{ "Endpoint", _options.Endpoint.Host },
{ "DnsProvider", certificatePolicyItem.DnsProviderName }
});

csr = certificateOperation.Properties.Csr;
Expand Down Expand Up @@ -416,10 +444,12 @@ public async Task<CertificateItem> MergeCertificate([ActivityTrigger] (string, O
}

[FunctionName(nameof(CleanupDnsChallenge))]
public async Task CleanupDnsChallenge([ActivityTrigger] IReadOnlyList<AcmeChallengeResult> challengeResults)
public async Task CleanupDnsChallenge([ActivityTrigger] (string, IReadOnlyList<AcmeChallengeResult>) input)
{
// DNS zone の一覧を取得する
var zones = await _dnsProviders.ListAllZonesAsync();
var (dnsProviderName, challengeResults) = input;

// DNS zone の一覧を各 Provider から取得
var zones = await (string.IsNullOrEmpty(dnsProviderName) ? _dnsProviders.ListAllZonesAsync() : _dnsProviders.ListZonesAsync(dnsProviderName));

// DNS-01 の検証レコード名毎に DNS から TXT レコードを削除
foreach (var lookup in challengeResults.ToLookup(x => x.DnsRecordName))
Expand Down
8 changes: 4 additions & 4 deletions KeyVault.Acmebot/Functions/SharedOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public async Task IssueCertificate([OrchestrationTrigger] IDurableOrchestrationC
var activity = context.CreateActivityProxy<ISharedActivity>();

// 前提条件をチェック
await activity.Dns01Precondition(certificatePolicy.DnsNames);
certificatePolicy.DnsProviderName = await activity.Dns01Precondition((certificatePolicy.DnsProviderName, certificatePolicy.DnsNames));

// 新しく ACME Order を作成する
var orderDetails = await activity.Order(certificatePolicy.DnsNames);
Expand All @@ -29,7 +29,7 @@ public async Task IssueCertificate([OrchestrationTrigger] IDurableOrchestrationC
if (orderDetails.Payload.Status != "ready")
{
// ACME DNS-01 Challenge を実行
var (challengeResults, propagationSeconds) = await activity.Dns01Authorization(orderDetails.Payload.Authorizations);
var (challengeResults, propagationSeconds) = await activity.Dns01Authorization((certificatePolicy.DnsProviderName, orderDetails.Payload.Authorizations));

// DNS Provider が指定した分だけ後続の処理を遅延させる
await context.CreateTimer(context.CurrentUtcDateTime.AddSeconds(propagationSeconds), CancellationToken.None);
Expand All @@ -43,8 +43,8 @@ public async Task IssueCertificate([OrchestrationTrigger] IDurableOrchestrationC
// ACME Order のステータスが ready になるまで 60 秒待機
await activity.CheckIsReady((orderDetails, challengeResults));

// 作成した DNS TXT レコードを削除
await activity.CleanupDnsChallenge(challengeResults);
// 作成した DNS レコードを削除
await activity.CleanupDnsChallenge((certificatePolicy.DnsProviderName, challengeResults));
}

// Key Vault で CSR を作成し Finalize を実行
Expand Down
20 changes: 20 additions & 0 deletions KeyVault.Acmebot/Internal/CertificateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static CertificateItem ToCertificateItem(this KeyVaultCertificateWithPoli
Id = certificate.Id,
Name = certificate.Name,
DnsNames = dnsNames is { Length: > 0 } ? dnsNames : new[] { certificate.Policy.Subject[3..] },
DnsProviderName = certificate.Properties.Tags.TryGetDnsProvider(out var dnsProviderName) ? dnsProviderName : "",
CreatedOn = certificate.Properties.CreatedOn.Value,
ExpiresOn = certificate.Properties.ExpiresOn.Value,
X509Thumbprint = ToHexString(certificate.Properties.X509Thumbprint),
Expand All @@ -42,15 +43,34 @@ public static CertificateItem ToCertificateItem(this KeyVaultCertificateWithPoli
};
}

public static CertificatePolicyItem ToCertificatePolicyItem(this KeyVaultCertificateWithPolicy certificate)
{
var dnsNames = certificate.Policy.SubjectAlternativeNames.DnsNames.ToArray();

return new CertificatePolicyItem
{
CertificateName = certificate.Name,
DnsNames = dnsNames.Length > 0 ? dnsNames : new[] { certificate.Policy.Subject[3..] },
DnsProviderName = certificate.Properties.Tags.TryGetDnsProvider(out var dnsProviderName) ? dnsProviderName : "",
KeyType = certificate.Policy.KeyType?.ToString(),
KeySize = certificate.Policy.KeySize,
KeyCurveName = certificate.Policy.KeyCurveName?.ToString(),
ReuseKey = certificate.Policy.ReuseKey
};
}

private const string IssuerKey = "Issuer";
private const string EndpointKey = "Endpoint";
private const string DnsProviderKey = "DnsProvider";

private const string IssuerValue = "Acmebot";

private static bool TryGetIssuer(this IDictionary<string, string> tags, out string issuer) => tags.TryGetValue(IssuerKey, out issuer);

private static bool TryGetEndpoint(this IDictionary<string, string> tags, out string endpoint) => tags.TryGetValue(EndpointKey, out endpoint);

private static bool TryGetDnsProvider(this IDictionary<string, string> tags, out string dnsProviderName) => tags.TryGetValue(DnsProviderKey, out dnsProviderName);

private static string ToHexString(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);
Expand Down
19 changes: 1 addition & 18 deletions KeyVault.Acmebot/Internal/CertificatePolicyExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,11 @@
using System.Linq;

using Azure.Security.KeyVault.Certificates;
using Azure.Security.KeyVault.Certificates;

using KeyVault.Acmebot.Models;

namespace KeyVault.Acmebot.Internal;

internal static class CertificatePolicyExtensions
{
public static CertificatePolicyItem ToCertificatePolicyItem(this CertificatePolicy certificatePolicy, string certificateName)
{
var dnsNames = certificatePolicy.SubjectAlternativeNames.DnsNames.ToArray();

return new CertificatePolicyItem
{
CertificateName = certificateName,
DnsNames = dnsNames.Length > 0 ? dnsNames : new[] { certificatePolicy.Subject[3..] },
KeyType = certificatePolicy.KeyType?.ToString(),
KeySize = certificatePolicy.KeySize,
KeyCurveName = certificatePolicy.KeyCurveName?.ToString(),
ReuseKey = certificatePolicy.ReuseKey
};
}

public static CertificatePolicy ToCertificatePolicy(this CertificatePolicyItem certificatePolicyItem)
{
var subjectAlternativeNames = new SubjectAlternativeNames();
Expand Down
12 changes: 12 additions & 0 deletions KeyVault.Acmebot/Internal/DnsProvidersExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ public static async Task<IReadOnlyList<DnsZone>> ListAllZonesAsync(this IEnumera
return zones.SelectMany(x => x).ToArray();
}

public static async Task<IReadOnlyList<DnsZone>> ListZonesAsync(this IEnumerable<IDnsProvider> dnsProviders, string dnsProviderName)
{
var dnsProvider = dnsProviders.FirstOrDefault(x => x.Name == dnsProviderName);

if (dnsProvider is null)
{
return Array.Empty<DnsZone>();
}

return await dnsProvider.ListZonesAsync();
}

public static void TryAdd<TOption>(this IList<IDnsProvider> dnsProviders, TOption options, Func<TOption, IDnsProvider> factory)
{
if (options is not null)
Expand Down
6 changes: 6 additions & 0 deletions KeyVault.Acmebot/Internal/DnsZoneExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
using System.Collections.Generic;
using System.Linq;

using KeyVault.Acmebot.Models;
using KeyVault.Acmebot.Providers;

namespace KeyVault.Acmebot.Internal;

internal static class DnsZoneExtensions
{
public static DnsZoneItem ToDnsZoneItem(this DnsZone dnsZone)
{
return new DnsZoneItem { Name = dnsZone.Name, DnsProviderName = dnsZone.DnsProvider.Name };
}

public static DnsZone FindDnsZone(this IEnumerable<DnsZone> dnsZones, string dnsName)
{
return dnsZones.Where(x => string.Equals(dnsName, x.Name, StringComparison.OrdinalIgnoreCase) || dnsName.EndsWith($".{x.Name}", StringComparison.OrdinalIgnoreCase))
Expand Down
10 changes: 5 additions & 5 deletions KeyVault.Acmebot/KeyVault.Acmebot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.Route53" Version="3.7.201.6" />
<PackageReference Include="Azure.Identity" Version="1.9.0" />
<PackageReference Include="AWSSDK.Route53" Version="3.7.202.7" />
<PackageReference Include="Azure.Identity" Version="1.10.3" />
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.0.1" />
<PackageReference Include="Azure.ResourceManager.PrivateDns" Version="1.0.1" />
<PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.5.1" />
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.5.0" />
<PackageReference Include="DnsClient" Version="1.7.0" />
<PackageReference Include="DurableTask.TypedProxy" Version="2.2.2" />
<PackageReference Include="Google.Apis.Dns.v1" Version="1.61.0.3134" />
<PackageReference Include="Google.Apis.Dns.v1" Version="1.62.0.3164" />
<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.DurableTask" Version="2.10.0" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.DurableTask" Version="2.12.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="[6.0.*,7.0.0)" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="4.2.0" />
<PackageReference Include="WebJobs.Extensions.HttpApi" Version="2.1.0" />
Expand All @@ -31,7 +31,7 @@
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
<None Update="wwwroot\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="DeleteFiles" AfterTargets="Publish">
Expand Down
3 changes: 3 additions & 0 deletions KeyVault.Acmebot/Models/CertificateItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public class CertificateItem
[JsonProperty("dnsNames")]
public IReadOnlyList<string> DnsNames { get; set; }

[JsonProperty("dnsProviderName")]
public string DnsProviderName { get; set; }

[JsonProperty("createdOn")]
public DateTimeOffset CreatedOn { get; set; }

Expand Down
3 changes: 3 additions & 0 deletions KeyVault.Acmebot/Models/CertificatePolicyItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public class CertificatePolicyItem : IValidatableObject
[JsonProperty("dnsNames")]
public string[] DnsNames { get; set; }

[JsonProperty("dnsProviderName")]
public string DnsProviderName { get; set; }

[JsonProperty("keyType")]
[RegularExpression("^(RSA|EC)$")]
public string KeyType { get; set; }
Expand Down
12 changes: 12 additions & 0 deletions KeyVault.Acmebot/Models/DnsZoneItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Newtonsoft.Json;

namespace KeyVault.Acmebot.Models;

public class DnsZoneItem
{
[JsonProperty("name")]
public string Name { get; set; }

[JsonProperty("dnsProviderName")]
public string DnsProviderName { get; set; }
}
Loading

0 comments on commit 756a267

Please sign in to comment.