diff --git a/KeyVault.Acmebot/Contracts/ISharedFunctions.cs b/KeyVault.Acmebot/Contracts/ISharedFunctions.cs index 28f57298..534cc103 100644 --- a/KeyVault.Acmebot/Contracts/ISharedFunctions.cs +++ b/KeyVault.Acmebot/Contracts/ISharedFunctions.cs @@ -23,15 +23,15 @@ public interface ISharedFunctions Task Dns01Precondition(string[] hostNames); - Task Dns01Authorization((string, string) input); + Task> Dns01Authorization(string[] authorizationUrls); [RetryOptions("00:00:10", 6, HandlerType = typeof(RetryStrategy), HandlerMethodName = nameof(RetryStrategy.RetriableException))] - Task CheckDnsChallenge(AcmeChallengeResult challenge); + Task CheckDnsChallenge(IList challengeResults); [RetryOptions("00:00:05", 12, HandlerType = typeof(RetryStrategy), HandlerMethodName = nameof(RetryStrategy.RetriableException))] Task CheckIsReady(OrderDetails orderDetails); - Task AnswerChallenges(IList challenges); + Task AnswerChallenges(IList challengeResults); Task FinalizeOrder((string[], OrderDetails) input); } diff --git a/KeyVault.Acmebot/Internal/AzureSdkExtensions.cs b/KeyVault.Acmebot/Internal/AzureSdkExtensions.cs index ac31832d..d1b81bbd 100644 --- a/KeyVault.Acmebot/Internal/AzureSdkExtensions.cs +++ b/KeyVault.Acmebot/Internal/AzureSdkExtensions.cs @@ -45,5 +45,17 @@ public static async Task> GetAllCertificatesAsync(this IK return certificates; } + + public static async Task GetOrDefaultAsync(this IRecordSetsOperations operations, string resourceGroupName, string zoneName, string relativeRecordSetName, RecordType recordType) + { + try + { + return await operations.GetAsync(resourceGroupName, zoneName, relativeRecordSetName, RecordType.TXT); + } + catch + { + return null; + } + } } } diff --git a/KeyVault.Acmebot/SharedFunctions.cs b/KeyVault.Acmebot/SharedFunctions.cs index db92a108..609fd947 100644 --- a/KeyVault.Acmebot/SharedFunctions.cs +++ b/KeyVault.Acmebot/SharedFunctions.cs @@ -41,8 +41,6 @@ public SharedFunctions(IHttpClientFactory httpClientFactory, LookupClient lookup _dnsManagementClient = dnsManagementClient; } - private const string InstanceIdKey = "InstanceId"; - private readonly IHttpClientFactory _httpClientFactory; private readonly LookupClient _lookupClient; private readonly IAcmeProtocolClientFactory _acmeProtocolClientFactory; @@ -63,22 +61,14 @@ public async Task IssueCertificate([OrchestrationTrigger] IDurableOrchestrationC // 新しく ACME Order を作成する var orderDetails = await activity.Order(dnsNames); - // 複数の Authorizations を処理する - var challenges = new List(); - - foreach (var authorization in orderDetails.Payload.Authorizations) - { - // ACME Challenge を実行 - var result = await activity.Dns01Authorization((authorization, context.ParentInstanceId ?? context.InstanceId)); + // ACME Challenge を実行 + var challengeResults = await activity.Dns01Authorization(orderDetails.Payload.Authorizations); - // Azure DNS で正しくレコードが引けるか確認 - await activity.CheckDnsChallenge(result); - - challenges.Add(result); - } + // Azure DNS で正しくレコードが引けるか確認 + await activity.CheckDnsChallenge(challengeResults); // ACME Answer を実行 - await activity.AnswerChallenges(challenges); + await activity.AnswerChallenges(challengeResults); // Order のステータスが ready になるまで 60 秒待機 await activity.CheckIsReady(orderDetails); @@ -135,106 +125,84 @@ public async Task Dns01Precondition([ActivityTrigger] string[] hostNames) } [FunctionName(nameof(Dns01Authorization))] - public async Task Dns01Authorization([ActivityTrigger] (string, string) input) + public async Task> Dns01Authorization([ActivityTrigger] string[] authorizationUrls) { - var (authzUrl, instanceId) = input; - var acmeProtocolClient = await _acmeProtocolClientFactory.CreateClientAsync(); - var authz = await acmeProtocolClient.GetAuthorizationDetailsAsync(authzUrl); + var challengeResults = new List(); - // DNS-01 Challenge の情報を拾う - var challenge = authz.Challenges.First(x => x.Type == "dns-01"); + foreach (var authorizationUrl in authorizationUrls) + { + // Authorization の詳細を取得 + var authorization = await acmeProtocolClient.GetAuthorizationDetailsAsync(authorizationUrl); - var challengeValidationDetails = AuthorizationDecoder.ResolveChallengeForDns01(authz, challenge, acmeProtocolClient.Signer); + // DNS-01 Challenge の情報を拾う + var challenge = authorization.Challenges.First(x => x.Type == "dns-01"); - // Azure DNS の TXT レコードを書き換え - var zones = await _dnsManagementClient.Zones.ListAllAsync(); + var challengeValidationDetails = AuthorizationDecoder.ResolveChallengeForDns01(authorization, challenge, acmeProtocolClient.Signer); - var zone = zones.Where(x => challengeValidationDetails.DnsRecordName.EndsWith($".{x.Name}", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(x => x.Name.Length) - .First(); + // Challenge の情報を保存する + challengeResults.Add(new AcmeChallengeResult + { + Url = challenge.Url, + DnsRecordName = challengeValidationDetails.DnsRecordName, + DnsRecordValue = challengeValidationDetails.DnsRecordValue + }); + } - var resourceGroup = ExtractResourceGroup(zone.Id); + // Azure DNS zone の一覧を取得する + var zones = await _dnsManagementClient.Zones.ListAllAsync(); - // Challenge の詳細から Azure DNS 向けにレコード名を作成 - var acmeDnsRecordName = challengeValidationDetails.DnsRecordName.Replace($".{zone.Name}", "", StringComparison.OrdinalIgnoreCase); + // DNS-01 の検証レコード名毎に Azure DNS に TXT レコードを作成 + foreach (var lookup in challengeResults.ToLookup(x => x.DnsRecordName)) + { + var dnsRecordName = lookup.Key; - RecordSet recordSet; + var zone = zones.Where(x => dnsRecordName.EndsWith($".{x.Name}", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.Name.Length) + .First(); - try - { - recordSet = await _dnsManagementClient.RecordSets.GetAsync(resourceGroup, zone.Name, acmeDnsRecordName, RecordType.TXT); - } - catch - { - recordSet = null; - } + var resourceGroup = ExtractResourceGroup(zone.Id); - if (recordSet != null) - { - if (recordSet.Metadata == null || !recordSet.Metadata.TryGetValue(InstanceIdKey, out var dnsInstanceId) || dnsInstanceId != instanceId) - { - recordSet.Metadata = new Dictionary - { - { InstanceIdKey, instanceId } - }; + // Challenge の詳細から Azure DNS 向けにレコード名を作成 + var acmeDnsRecordName = dnsRecordName.Replace($".{zone.Name}", "", StringComparison.OrdinalIgnoreCase); - recordSet.TxtRecords.Clear(); - } + // 既存の TXT レコードがあれば取得する + var recordSet = await _dnsManagementClient.RecordSets.GetOrDefaultAsync(resourceGroup, zone.Name, acmeDnsRecordName, RecordType.TXT) ?? new RecordSet(); + // TXT レコードに TTL と値をセットする recordSet.TTL = 60; + recordSet.TxtRecords = lookup.Select(x => new TxtRecord(new[] { x.DnsRecordValue })).ToArray(); - // 既存の TXT レコードに値を追加する - recordSet.TxtRecords.Add(new TxtRecord(new[] { challengeValidationDetails.DnsRecordValue })); - } - else - { - // 新しく TXT レコードを作成する - recordSet = new RecordSet - { - TTL = 60, - Metadata = new Dictionary - { - { InstanceIdKey, instanceId } - }, - TxtRecords = new[] - { - new TxtRecord(new[] { challengeValidationDetails.DnsRecordValue }) - } - }; + await _dnsManagementClient.RecordSets.CreateOrUpdateAsync(resourceGroup, zone.Name, acmeDnsRecordName, RecordType.TXT, recordSet); } - await _dnsManagementClient.RecordSets.CreateOrUpdateAsync(resourceGroup, zone.Name, acmeDnsRecordName, RecordType.TXT, recordSet); - - return new AcmeChallengeResult - { - Url = challenge.Url, - DnsRecordName = challengeValidationDetails.DnsRecordName, - DnsRecordValue = challengeValidationDetails.DnsRecordValue - }; + return challengeResults; } [FunctionName(nameof(CheckDnsChallenge))] - public async Task CheckDnsChallenge([ActivityTrigger] AcmeChallengeResult challenge) + public async Task CheckDnsChallenge([ActivityTrigger] IList challengeResults) { - // 実際に ACME の TXT レコードを引いて確認する - var queryResult = await _lookupClient.QueryAsync(challenge.DnsRecordName, QueryType.TXT); + foreach (var challengeResult in challengeResults) + { + // 実際に ACME の TXT レコードを引いて確認する + var queryResult = await _lookupClient.QueryAsync(challengeResult.DnsRecordName, QueryType.TXT); - var txtRecords = queryResult.Answers - .OfType() - .ToArray(); + var txtRecords = queryResult.Answers + .OfType() + .ToArray(); - // レコードが存在しなかった場合はエラー - if (txtRecords.Length == 0) - { - throw new RetriableActivityException($"{challenge.DnsRecordName} did not resolve."); - } + // レコードが存在しなかった場合はエラー + if (txtRecords.Length == 0) + { + throw new RetriableActivityException($"{challengeResult.DnsRecordName} did not resolve."); + } - // レコードに今回のチャレンジが含まれていない場合もエラー - if (!txtRecords.Any(x => x.Text.Contains(challenge.DnsRecordValue))) - { - throw new RetriableActivityException($"{challenge.DnsRecordName} value is not correct."); + // レコードに今回のチャレンジが含まれていない場合もエラー + if (!txtRecords.Any(x => x.Text.Contains(challengeResult.DnsRecordValue))) + { + throw new RetriableActivityException($"{challengeResult.DnsRecordName} value is not correct."); + } } } @@ -259,14 +227,14 @@ public async Task CheckIsReady([ActivityTrigger] OrderDetails orderDetails) } [FunctionName(nameof(AnswerChallenges))] - public async Task AnswerChallenges([ActivityTrigger] IList challenges) + public async Task AnswerChallenges([ActivityTrigger] IList challengeResults) { var acmeProtocolClient = await _acmeProtocolClientFactory.CreateClientAsync(); // Answer の準備が出来たことを通知 - foreach (var challenge in challenges) + foreach (var challengeResult in challengeResults) { - await acmeProtocolClient.AnswerChallengeAsync(challenge.Url); + await acmeProtocolClient.AnswerChallengeAsync(challengeResult.Url); } }