Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Implement Event Webhook #43

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions AzureAppService.LetsEncrypt/AddCertificate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
using ACMESharp.Protocol;
using ACMESharp.Protocol.Resources;

using AzureAppService.LetsEncrypt.Internal;

using Microsoft.Azure.Management.WebSites.Models;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
Expand Down Expand Up @@ -61,18 +63,23 @@ public static async Task RunOrchestrator([OrchestrationTrigger] DurableOrchestra
var orderDetails = await context.CallActivityAsync<OrderDetails>(nameof(SharedFunctions.Order), request.Domains);

// 複数の Authorizations を処理する
var challenges = new List<Challenge>();
var challenges = new List<ChallengeResult>();

foreach (var authorization in orderDetails.Payload.Authorizations)
{
// ACME Challenge を実行
if (useDns01Auth)
{
challenges.Add(await context.CallActivityAsync<Challenge>(nameof(SharedFunctions.Dns01Authorization), authorization));
var result = await context.CallActivityAsync<ChallengeResult>(nameof(SharedFunctions.Dns01Authorization), authorization);

// Azure DNS で正しくレコードが引けるか確認
await context.CallActivityWithRetryAsync(nameof(SharedFunctions.CheckIsDnsRecord), new RetryOptions(TimeSpan.FromSeconds(10), 6), result);

challenges.Add(result);
}
else
{
challenges.Add(await context.CallActivityAsync<Challenge>(nameof(SharedFunctions.Http01Authorization), (site, authorization)));
challenges.Add(await context.CallActivityAsync<ChallengeResult>(nameof(SharedFunctions.Http01Authorization), (site, authorization)));
}
}

Expand All @@ -95,6 +102,17 @@ public static async Task RunOrchestrator([OrchestrationTrigger] DurableOrchestra
}

await context.CallActivityAsync(nameof(SharedFunctions.UpdateSiteBinding), site);

// Webhook を実行
await context.CallActivityWithRetryAsync(nameof(SharedFunctions.RaiseEventWebhook), new RetryOptions(TimeSpan.FromSeconds(10), 5), new WebhookPayload
{
IsSuccess = true,
Action = nameof(AddCertificate),
ResourceGroup = site.ResourceGroup,
SiteName = site.SiteName(),
SlotName = site.SlotName(),
HostNames = request.Domains
});
}

[FunctionName("AddCertificate_HttpStart")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="ACMESharpCore" Version="2.0.0.49-beta1" />
<PackageReference Include="ACMESharpCore.Crypto" Version="2.0.0.41-beta1" />
<PackageReference Include="DnsClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Azure.Management.Dns" Version="3.0.1" />
<PackageReference Include="Microsoft.Azure.Management.WebSites" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.0.3" />
Expand Down
10 changes: 4 additions & 6 deletions AzureAppService.LetsEncrypt/GetSitesInformation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static async Task<IList<ResourceGroupInformation>> RunOrchestrator([Orche
Sites = new List<SiteInformation>()
};

foreach (var site in item.ToLookup(x => x.SplitName().Item1))
foreach (var site in item.ToLookup(x => x.SiteName()))
{
var siteInformation = new SiteInformation
{
Expand All @@ -44,11 +44,9 @@ public static async Task<IList<ResourceGroupInformation>> RunOrchestrator([Orche

foreach (var slot in site)
{
var (_, slotName) = slot.SplitName();

var slotInformation = new SlotInformation
{
Name = slotName ?? "production",
Name = slot.SlotName() ?? "production",
Domains = slot.HostNameSslStates
.Where(x => x.SslState == SslState.Disabled && !x.Name.EndsWith(".azurewebsites.net"))
.Select(x => x.Name)
Expand All @@ -72,7 +70,7 @@ public static async Task<IList<ResourceGroupInformation>> RunOrchestrator([Orche
result.Add(resourceGroup);
}
}

return result;
}

Expand Down Expand Up @@ -120,6 +118,6 @@ public class SlotInformation
public string Name { get; set; }

[JsonProperty("domains")]
public IList<string> Domains{ get; set; }
public IList<string> Domains { get; set; }
}
}
2 changes: 2 additions & 0 deletions AzureAppService.LetsEncrypt/Internal/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public Settings()

public string SubscriptionId => _section[nameof(SubscriptionId)];

public string Webhook => _section[nameof(Webhook)];

public static Settings Default { get; } = new Settings();
}
}
10 changes: 10 additions & 0 deletions AzureAppService.LetsEncrypt/Internal/SiteExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ public static bool IsSlot(this Site site)
return site.Name.Contains('/');
}

public static string SiteName(this Site site)
{
return site.SplitName().Item1;
}

public static string SlotName(this Site site)
{
return site.SplitName().Item2;
}

public static (string, string) SplitName(this Site site)
{
var index = site.Name.IndexOf('/');
Expand Down
25 changes: 25 additions & 0 deletions AzureAppService.LetsEncrypt/Internal/WebhookPayload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Newtonsoft.Json;

namespace AzureAppService.LetsEncrypt.Internal
{
internal class WebhookPayload
{
[JsonProperty("isSuccess")]
public bool IsSuccess { get; set; }

[JsonProperty("action")]
public string Action { get; set; }

[JsonProperty("resourceGroup")]
public string ResourceGroup { get; set; }

[JsonProperty("siteName")]
public string SiteName { get; set; }

[JsonProperty("slotName")]
public string SlotName { get; set; }

[JsonProperty("hostNames")]
public string[] HostNames { get; set; }
}
}
24 changes: 21 additions & 3 deletions AzureAppService.LetsEncrypt/RenewCertificates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using ACMESharp.Protocol;
using ACMESharp.Protocol.Resources;

using AzureAppService.LetsEncrypt.Internal;

using Microsoft.Azure.Management.WebSites.Models;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -87,18 +89,23 @@ public static async Task RenewSiteCertificates([OrchestrationTrigger] DurableOrc
var orderDetails = await context.CallActivityAsync<OrderDetails>(nameof(SharedFunctions.Order), certificate.HostNames);

// 複数の Authorizations を処理する
var challenges = new List<Challenge>();
var challenges = new List<ChallengeResult>();

foreach (var authorization in orderDetails.Payload.Authorizations)
{
// ACME Challenge を実行
if (useDns01Auth)
{
challenges.Add(await context.CallActivityAsync<Challenge>(nameof(SharedFunctions.Dns01Authorization), authorization));
var result = await context.CallActivityAsync<ChallengeResult>(nameof(SharedFunctions.Dns01Authorization), authorization);

// Azure DNS で正しくレコードが引けるか確認
await context.CallActivityWithRetryAsync(nameof(SharedFunctions.CheckIsDnsRecord), new RetryOptions(TimeSpan.FromSeconds(10), 6), result);

challenges.Add(result);
}
else
{
challenges.Add(await context.CallActivityAsync<Challenge>(nameof(SharedFunctions.Http01Authorization), (site, authorization)));
challenges.Add(await context.CallActivityAsync<ChallengeResult>(nameof(SharedFunctions.Http01Authorization), (site, authorization)));
}
}

Expand All @@ -121,6 +128,17 @@ public static async Task RenewSiteCertificates([OrchestrationTrigger] DurableOrc
}

await context.CallActivityAsync(nameof(SharedFunctions.UpdateSiteBinding), site);

// Webhook を実行
await context.CallActivityWithRetryAsync(nameof(SharedFunctions.RaiseEventWebhook), new RetryOptions(TimeSpan.FromSeconds(10), 5), new WebhookPayload
{
IsSuccess = true,
Action = nameof(RenewCertificates),
ResourceGroup = site.ResourceGroup,
SiteName = site.SiteName(),
SlotName = site.SlotName(),
HostNames = certificates.SelectMany(x => x.HostNames).ToArray()
});
}

[FunctionName("RenewCertificates_Timer")]
Expand Down
96 changes: 86 additions & 10 deletions AzureAppService.LetsEncrypt/SharedFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

using AzureAppService.LetsEncrypt.Internal;

using DnsClient;

using Microsoft.Azure.Management.Dns;
using Microsoft.Azure.Management.Dns.Models;
using Microsoft.Azure.Management.WebSites;
Expand All @@ -32,6 +34,8 @@ public static class SharedFunctions
private static readonly HttpClient _httpClient = new HttpClient();
private static readonly HttpClient _acmeHttpClient = new HttpClient { BaseAddress = new Uri("https://acme-v02.api.letsencrypt.org/") };

private static readonly LookupClient _lookupClient = new LookupClient { UseCache = false };

[FunctionName(nameof(GetSite))]
public static async Task<Site> GetSite([ActivityTrigger] DurableActivityContext context, ILogger log)
{
Expand Down Expand Up @@ -125,7 +129,7 @@ public static async Task Http01Precondition([ActivityTrigger] DurableActivityCon
}

[FunctionName(nameof(Http01Authorization))]
public static async Task<Challenge> Http01Authorization([ActivityTrigger] DurableActivityContext context, ILogger log)
public static async Task<ChallengeResult> Http01Authorization([ActivityTrigger] DurableActivityContext context, ILogger log)
{
var (site, authzUrl) = context.GetInput<(Site, string)>();

Expand All @@ -148,7 +152,10 @@ public static async Task<Challenge> Http01Authorization([ActivityTrigger] Durabl
await kuduClient.WriteFileAsync(DefaultWebConfigPath, DefaultWebConfig);
await kuduClient.WriteFileAsync(challengeValidationDetails.HttpResourcePath, challengeValidationDetails.HttpResourceValue);

return challenge;
return new ChallengeResult
{
Url = challenge.Url
};
}

[FunctionName(nameof(Dns01Precondition))]
Expand All @@ -165,15 +172,13 @@ public static async Task Dns01Precondition([ActivityTrigger] DurableActivityCont
{
if (!zones.Any(x => hostName.EndsWith(x.Name)))
{
log.LogError($"Azure DNS zone \"{hostName}\" is not found");

throw new InvalidOperationException();
throw new InvalidOperationException($"Azure DNS zone \"{hostName}\" is not found");
}
}
}

[FunctionName(nameof(Dns01Authorization))]
public static async Task<Challenge> Dns01Authorization([ActivityTrigger] DurableActivityContext context, ILogger log)
public static async Task<ChallengeResult> Dns01Authorization([ActivityTrigger] DurableActivityContext context, ILogger log)
{
var authzUrl = context.GetInput<string>();

Expand Down Expand Up @@ -241,13 +246,43 @@ public static async Task<Challenge> Dns01Authorization([ActivityTrigger] Durable

await dnsClient.RecordSets.CreateOrUpdateAsync(resourceId["resourceGroups"], zone.Name, acmeDnsRecordName, RecordType.TXT, recordSet);

return challenge;
return new ChallengeResult
{
Url = challenge.Url,
DnsRecordName = challengeValidationDetails.DnsRecordName,
DnsRecordValue = challengeValidationDetails.DnsRecordValue
};
}

[FunctionName(nameof(CheckIsDnsRecord))]
public static async Task CheckIsDnsRecord([ActivityTrigger] DurableActivityContext context, ILogger log)
{
var challenge = context.GetInput<ChallengeResult>();

// 実際に ACME の TXT レコードを引いて確認する
var queryResult = await _lookupClient.QueryAsync(challenge.DnsRecordName, QueryType.TXT);

var txtRecord = queryResult.Answers
.OfType<DnsClient.Protocol.TxtRecord>()
.FirstOrDefault();

// レコードが存在しなかった場合はエラー
if (txtRecord == null)
{
throw new InvalidOperationException($"{challenge.DnsRecordName} did not resolve.");
}

// レコードに今回のチャレンジが含まれていない場合もエラー
if (!txtRecord.Text.Contains(challenge.DnsRecordValue))
{
throw new InvalidOperationException($"{challenge.DnsRecordName} value is not correct.");
}
}

[FunctionName(nameof(AnswerChallenges))]
public static async Task AnswerChallenges([ActivityTrigger] DurableActivityContext context, ILogger log)
{
var challenges = context.GetInput<IList<Challenge>>();
var challenges = context.GetInput<IList<ChallengeResult>>();

var acme = await CreateAcmeClientAsync();

Expand All @@ -267,9 +302,28 @@ public static async Task CheckIsReady([ActivityTrigger] DurableActivityContext c

orderDetails = await acme.GetOrderDetailsAsync(orderDetails.OrderUrl, orderDetails);

if (orderDetails.Payload.Status != "ready")
if (orderDetails.Payload.Status == "pending")
{
// pending の場合は何もしない
throw new InvalidOperationException("ACME domain validation is pending.");
}

if (orderDetails.Payload.Status == "invalid")
{
throw new InvalidOperationException($"Invalid order status is {orderDetails.Payload.Status}");
// エラーログ用に Authorization を取得
foreach (var authzUrl in orderDetails.Payload.Authorizations)
{
var authorization = await acme.GetAuthorizationDetailsAsync(authzUrl);

var challenge = authorization.Challenges.FirstOrDefault(x => x.Error != null);

if (challenge != null)
{
log.LogError(JsonConvert.SerializeObject(challenge.Error));
}
}

throw new InvalidOperationException("Invalid order status. Required retry at first.");
}
}

Expand Down Expand Up @@ -334,6 +388,21 @@ public static async Task DeleteCertificate([ActivityTrigger] DurableActivityCont
await websiteClient.Certificates.DeleteAsync(resourceId["resourceGroups"], certificate.Name);
}

[FunctionName(nameof(RaiseEventWebhook))]
public static async Task RaiseEventWebhook([ActivityTrigger] DurableActivityContext context, ILogger log)
{
if (string.IsNullOrEmpty(Settings.Default.Webhook))
{
return;
}

var payload = context.GetInput<WebhookPayload>();

var response = await _httpClient.PostAsJsonAsync(Settings.Default.Webhook, payload);

response.EnsureSuccessStatusCode();
}

private static async Task<AcmeProtocolClient> CreateAcmeClientAsync()
{
var account = default(AccountDetails);
Expand Down Expand Up @@ -448,4 +517,11 @@ private static IDictionary<string, string> ParseResourceId(string resourceId)
private static readonly string DefaultWebConfigPath = ".well-known/web.config";
private static readonly string DefaultWebConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n<configuration>\r\n <system.webServer>\r\n <handlers>\r\n <clear />\r\n <add name=\"StaticFile\" path=\"*\" verb=\"*\" modules=\"StaticFileModule\" resourceType=\"Either\" requireAccess=\"Read\" />\r\n </handlers>\r\n <staticContent>\r\n <remove fileExtension=\".\" />\r\n <mimeMap fileExtension=\".\" mimeType=\"text/plain\" />\r\n </staticContent>\r\n </system.webServer>\r\n <system.web>\r\n <authorization>\r\n <allow users=\"*\"/>\r\n </authorization>\r\n </system.web>\r\n</configuration>";
}

public class ChallengeResult
{
public string Url { get; set; }
public string DnsRecordName { get; set; }
public string DnsRecordValue { get; set; }
}
}
Loading