From 00a9585f9e2039a43010a940756809be019d0e31 Mon Sep 17 00:00:00 2001 From: Thomas Tyack Date: Tue, 25 Aug 2026 13:54:18 +1000 Subject: [PATCH] Fix stale renewal check and ACME DNS validation race - NeedsNewCertificateAsync() filtered existing certs by issuer containing "Let's Encrypt Authority" (the pre-2021 intermediate name). Current certs are issued by R11/R12/R13/YR1 etc., so the filter never matched, existingCert was always null, and the tool requested a brand-new certificate on every scheduled run instead of honoring TimeBeforeExpiryToRenew. Upstream's own fix for this (n3wt0n#30) just hardcoded "R3" instead, which is equally stale today. Dropped the issuer filter entirely - ExistingCertificates is already scoped to the target hostname. - GetCertificateAsync() called dnsChallenge.Validate() immediately after creating the DNS TXT record, before Azure DNS had time to propagate it. A premature check fails the challenge and flips the authorization out of "pending", so the retry loop's subsequent Validate() calls error with "authorization must be pending" instead of actually retrying. Matches upstream issue n3wt0n#48/#50. Added a configurable delay (default 15s, WaitTimeBeforeValidateInSeconds) before the first validation attempt. --- src/WebAppSSLManager/AzureHelper.cs | 5 ++++- src/WebAppSSLManager/CertificatesHelper.cs | 7 +++++++ src/WebAppSSLManager/Models/Constants.cs | 1 + src/WebAppSSLManager/Models/Settings.cs | 9 +++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/WebAppSSLManager/AzureHelper.cs b/src/WebAppSSLManager/AzureHelper.cs index f62abe2..4f003f7 100644 --- a/src/WebAppSSLManager/AzureHelper.cs +++ b/src/WebAppSSLManager/AzureHelper.cs @@ -136,8 +136,11 @@ public static async Task NeedsNewCertificateAsync() { ResourceConfiguration resource = await GetResourceConfigurationAsync(); + // Not filtering by issuer here: Let's Encrypt rotates its intermediate CA name over time + // (was "Let's Encrypt Authority X3", then "R3", now "R11"/"R12"/"R13"/"YR1", etc.), so any + // hardcoded issuer match goes stale. ExistingCertificates is already scoped to this hostname + // by GetResourceConfigurationAsync, so picking the latest-expiring one here is sufficient. IAppServiceCertificate existingCert = resource.ExistingCertificates? - .Where(c => c.Issuer.Contains(Constants.DefaultCA)) .OrderByDescending(c => c.ExpirationDate) .FirstOrDefault(); diff --git a/src/WebAppSSLManager/CertificatesHelper.cs b/src/WebAppSSLManager/CertificatesHelper.cs index d2ae73b..023c42c 100644 --- a/src/WebAppSSLManager/CertificatesHelper.cs +++ b/src/WebAppSSLManager/CertificatesHelper.cs @@ -125,6 +125,13 @@ public static async Task GetCertificateAsync() await AzureHelper.RemoveDNSVerificationTXTRecord(recordName); //to be sure we start clean await AzureHelper.CreateDNSVerificationTXTRecord(recordName, dnsTxt); + // Validating immediately after creating the TXT record races Azure DNS propagation: Let's + // Encrypt can check before the record is live, which fails the challenge and flips the + // authorization out of "pending" - so every retry after that first failure errors with + // "authorization must be pending" instead of actually retrying. Give DNS time to propagate first. + _logger.LogInformation($" Waiting {Settings.WaitTimeBeforeValidate.TotalSeconds}s for DNS propagation before validating..."); + await Task.Delay(Settings.WaitTimeBeforeValidate); + _logger.LogInformation($" Validating DNS authorization challenge. Can take up to 90 seconds..."); var validatedChallege = await dnsChallenge.Validate(); var waitUntil = DateTime.Now.AddSeconds(90); diff --git a/src/WebAppSSLManager/Models/Constants.cs b/src/WebAppSSLManager/Models/Constants.cs index 805de43..febe50f 100644 --- a/src/WebAppSSLManager/Models/Constants.cs +++ b/src/WebAppSSLManager/Models/Constants.cs @@ -13,5 +13,6 @@ public class Constants public const string DefaultCA = "Let's Encrypt Authority"; public const int DefaultBatchSize = 0; public static readonly TimeSpan DefaultTimeBeforeExpiryToRenewCertificate = TimeSpan.FromDays(30); + public static readonly TimeSpan DefaultWaitTimeBeforeValidate = TimeSpan.FromSeconds(15); } } diff --git a/src/WebAppSSLManager/Models/Settings.cs b/src/WebAppSSLManager/Models/Settings.cs index 6abce1a..9d39201 100644 --- a/src/WebAppSSLManager/Models/Settings.cs +++ b/src/WebAppSSLManager/Models/Settings.cs @@ -18,6 +18,7 @@ public static class Settings public static bool UseStaging { get; private set; } public static int BatchSize { get; private set; } public static TimeSpan TimeBeforeExpiryToRenew { get; private set; } + public static TimeSpan WaitTimeBeforeValidate { get; private set; } public static void Init(ILogger logger) { @@ -97,6 +98,14 @@ public static void Init(ILogger logger) _logger.LogWarning("TimeBeforeExpiryToRenew environment variable is null or invalid. Reverting to default"); TimeBeforeExpiryToRenew = Constants.DefaultTimeBeforeExpiryToRenewCertificate; } + + if (int.TryParse(Environment.GetEnvironmentVariable("WaitTimeBeforeValidateInSeconds"), out int waitSeconds) && waitSeconds >= 0) + WaitTimeBeforeValidate = TimeSpan.FromSeconds(waitSeconds); + else + { + _logger.LogWarning("WaitTimeBeforeValidateInSeconds environment variable is null or invalid. Reverting to default"); + WaitTimeBeforeValidate = Constants.DefaultWaitTimeBeforeValidate; + } } } }