diff --git a/AcmeCaPlugin/AcmeCaPlugin.cs b/AcmeCaPlugin/AcmeCaPlugin.cs index fd7a2d8..5b945b2 100644 --- a/AcmeCaPlugin/AcmeCaPlugin.cs +++ b/AcmeCaPlugin/AcmeCaPlugin.cs @@ -1,27 +1,28 @@ // Copyright 2025 Keyfactor // Licensed under the Apache License, Version 2.0 -using ACMESharp.Authorizations; -using ACMESharp.Protocol; -using ACMESharp.Protocol.Resources; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme; -using Keyfactor.Extensions.CAPlugin.Acme.Clients.DNS; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Org.BouncyCastle.Asn1.Pkcs; -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Pkcs; -using System.Security.Cryptography; -using System; -using System.Collections.Generic; using System.Linq; using System.Net.Http; +using ACMESharp.Authorizations; +using Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme; +using System.Threading; +using ACMESharp.Protocol.Resources; +using ACMESharp.Protocol; using System.Text; +using Keyfactor.Extensions.CAPlugin.Acme.Clients.DNS; using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Pkcs; +using System.Security.Cryptography; namespace Keyfactor.Extensions.CAPlugin.Acme { @@ -62,7 +63,6 @@ public class AcmeCaPlugin : IAnyCAPlugin { private static readonly ILogger _logger = LogHandler.GetClassLogger(); private IAnyCAPluginConfigProvider Config { get; set; } - private readonly IDomainValidatorFactory _validatorFactory; private AcmeClientConfig _config; // Constants for better maintainability @@ -71,16 +71,6 @@ public class AcmeCaPlugin : IAnyCAPlugin private const int DNS_PROPAGATION_DELAY_SECONDS = 30; private const string USER_AGENT = "KeyfactorAcmePlugin/1.0"; - /// - /// Constructor requires domain validator factory for plugin-based DNS providers - /// - /// Factory to resolve domain validators from plugins (Required) - public AcmeCaPlugin(IDomainValidatorFactory validatorFactory) - { - _validatorFactory = validatorFactory ?? throw new ArgumentNullException(nameof(validatorFactory), - "IDomainValidatorFactory is required. DNS providers are now externalized as plugins."); - } - /// /// Initialize the plugin with configuration and certificate data reader /// @@ -88,7 +78,6 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa { _logger.MethodEntry(); Config = configProvider ?? throw new ArgumentNullException(nameof(configProvider)); - _config = GetConfig(); _logger.LogTrace("Enabled: {Enabled}", _config.Enabled); @@ -99,79 +88,9 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa return; } - // Validate that factory is available - validators will be resolved per-domain during enrollment - if (_validatorFactory == null) - { - var errorMsg = "IDomainValidatorFactory is required. DNS providers are now loaded as external plugins. " + - "Ensure the Keyfactor platform is configured to inject the factory."; - _logger.LogError(errorMsg); - throw new InvalidOperationException(errorMsg); - } - - ValidateConfigForEnrollment(_config); - - _logger.LogInformation("IDomainValidatorFactory available - domain validators will be resolved per-domain during enrollment"); - _logger.MethodExit(); } - /// - /// Fail-fast configuration sanity checks that run at Initialize time (when the connector is Enabled) - /// so operators see problems during save instead of on first enrollment attempt. - /// - private static void ValidateConfigForEnrollment(AcmeClientConfig config) - { - var problems = new List(); - - if (string.IsNullOrWhiteSpace(config.DirectoryUrl)) - { - problems.Add($"{nameof(AcmeClientConfig.DirectoryUrl)} is required."); - } - else if (!Uri.TryCreate(config.DirectoryUrl, UriKind.Absolute, out var uri) || - (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) - { - problems.Add($"{nameof(AcmeClientConfig.DirectoryUrl)} must be an absolute http(s) URL (got '{config.DirectoryUrl}')."); - } - - if (string.IsNullOrWhiteSpace(config.Email)) - { - problems.Add($"{nameof(AcmeClientConfig.Email)} is required for ACME account registration."); - } - - // EAB credentials must be supplied as a pair (or not at all) - var hasKid = !string.IsNullOrWhiteSpace(config.EabKid); - var hasHmac = !string.IsNullOrWhiteSpace(config.EabHmacKey); - if (hasKid != hasHmac) - { - problems.Add($"{nameof(AcmeClientConfig.EabKid)} and {nameof(AcmeClientConfig.EabHmacKey)} must both be provided together, or neither."); - } - - if (config.DnsPropagationDelaySeconds < 0) - { - problems.Add($"{nameof(AcmeClientConfig.DnsPropagationDelaySeconds)} must be >= 0 (got {config.DnsPropagationDelaySeconds})."); - } - - if (problems.Count > 0) - { - var joined = string.Join(" ", problems); - _logger.LogError("Configuration validation failed: {Problems}", joined); - throw new ArgumentException($"ACME CA connector configuration is invalid: {joined}"); - } - } - - /// - /// Simple implementation of IDomainValidatorConfigProvider to pass configuration to plugins - /// - private class DomainValidatorConfigProvider : IDomainValidatorConfigProvider - { - public Dictionary DomainValidationConfiguration { get; } - - public DomainValidatorConfigProvider(Dictionary config) - { - DomainValidationConfiguration = config ?? new Dictionary(); - } - } - /// /// Health check method - currently no-op for ACME /// @@ -336,81 +255,78 @@ public async Task Enroll( { _logger.MethodEntry(); - using var flow = new FlowLogger(_logger, $"Enroll:{subject ?? ""}"); - HttpClient httpClient = null; - string orderIdentifier = null; - - try + if (!_config.Enabled) { - if (!_config.Enabled) + _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Enrollment rejected."); + _logger.MethodExit(); + return new EnrollmentResult { - flow.Fail("EnabledCheck", "CA connector is disabled"); - _logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Enrollment rejected."); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"CA connector is disabled. Enable it in the CA configuration to perform enrollments.\n\n{flow.GetSummary()}" - }; - } + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "CA connector is disabled. Enable it in the CA configuration to perform enrollments." + }; + } - flow.Step("ValidateInput", () => - { - if (string.IsNullOrWhiteSpace(csr)) - throw new ArgumentException("CSR cannot be null or empty", nameof(csr)); - if (string.IsNullOrWhiteSpace(subject)) - throw new ArgumentException("Subject cannot be null or empty", nameof(subject)); - }, detail: subject); + if (string.IsNullOrWhiteSpace(csr)) + throw new ArgumentException("CSR cannot be null or empty", nameof(csr)); + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or empty", nameof(subject)); - csr = flow.Step("FormatCsr", () => FormatCsrToSingleLine(csr)); + csr = FormatCsrToSingleLine(csr); - var config = flow.Step("LoadConfig", () => GetConfig(), detail: _config.DirectoryUrl); + HttpClient httpClient = null; - httpClient = flow.Step("CreateHttpClient", () => - { - var handler = new LoggingHandler(new HttpClientHandler()); - var c = new HttpClient(handler); - c.DefaultRequestHeaders.UserAgent.TryParseAdd(USER_AGENT); - return c; - }); - - var (protocolClient, accountDetails, signer) = await flow.StepAsync("InitAcmeAccount", - async () => - { - var clientManager = new AcmeClientManager(_logger, config, httpClient); - return await clientManager.CreateClientAsync(); - }, - detail: config.DirectoryUrl); + try + { + var config = GetConfig(); + var handler = new LoggingHandler(new HttpClientHandler()); + httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(USER_AGENT); - var acmeClient = flow.Step("CreateAcmeClient", () => new AcmeClient(_logger, config, httpClient, protocolClient.Directory, - new Clients.Acme.Account(accountDetails, signer))); + // Init ACME client + var clientManager = new AcmeClientManager(_logger, config, httpClient); + var (protocolClient, accountDetails, signer) = await clientManager.CreateClientAsync(); + var acmeClient = new AcmeClient(_logger, config, httpClient, protocolClient.Directory, + new Clients.Acme.Account(accountDetails, signer)); - var csrBytes = flow.Step("DecodeCsr", () => Convert.FromBase64String(csr)); + // Decode CSR first so we can extract all domains from it + var csrBytes = Convert.FromBase64String(csr); - var identifiers = flow.Step("ExtractDomainsFromCsr", () => ExtractDomainsFromCsr(csrBytes), - detail: $"will be populated per-step"); + // Extract all domains directly from CSR (CN + SANs) for the ACME order + // This ensures we authorize exactly what's in the CSR + var identifiers = ExtractDomainsFromCsr(csrBytes); - var order = await flow.StepAsync("CreateOrder", - async () => await acmeClient.CreateOrderAsync(identifiers, null), - detail: $"{identifiers.Count} identifier(s)"); + // Create order + var order = await acmeClient.CreateOrderAsync(identifiers, null); _logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}", order.OrderUrl, order.Payload?.Status); - orderIdentifier = flow.Step("ExtractOrderIdentifier", () => ExtractOrderIdentifier(order.OrderUrl)); + // Extract order identifier BEFORE finalization to ensure we use the original order URL + var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl); - await ProcessAuthorizations(acmeClient, order, config, flow); + // Store pending order immediately + var accountId = accountDetails.Kid.Split('/').Last(); - order = await flow.StepAsync("FinalizeOrder", - async () => await acmeClient.FinalizeOrderAsync(order, csrBytes), - detail: order.OrderUrl); + // Process challenges, then finalize. Challenge TXT records are removed afterwards + // whether or not the order succeeded. + var createdRecords = new List(); + try + { + await ProcessAuthorizations(acmeClient, order, config, createdRecords); - if (order.Payload?.Status == "valid" && !string.IsNullOrEmpty(order.Payload.Certificate)) + // Finalize with original CSR bytes + order = await acmeClient.FinalizeOrderAsync(order, csrBytes); + } + finally { - var certBytes = await flow.StepAsync("DownloadCertificate", - async () => await acmeClient.GetCertificateAsync(order)); + await CleanupDnsRecordsAsync(config, createdRecords); + } - var certPem = flow.Step("EncodeCertificateToPem", () => EncodeToPem(certBytes, "CERTIFICATE"), - detail: $"{certBytes?.Length ?? 0} bytes"); + // If order is valid immediately, download cert + if (order.Payload?.Status == "valid" && !string.IsNullOrEmpty(order.Payload.Certificate)) + { + var certBytes = await acmeClient.GetCertificateAsync(order); + var certPem = EncodeToPem(certBytes, "CERTIFICATE"); _logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED", order.OrderUrl, orderIdentifier); @@ -419,32 +335,29 @@ public async Task Enroll( { CARequestID = orderIdentifier, Certificate = certPem, - Status = (int)EndEntityStatus.GENERATED, - StatusMessage = $"Enrollment completed successfully for {subject}.\n\n{flow.GetSummary()}" + Status = (int)EndEntityStatus.GENERATED }; } else { - flow.Fail("CertificateNotReady", $"Order status: {order.Payload?.Status ?? "unknown"}"); _logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}", order.OrderUrl, orderIdentifier, order.Payload?.Status); + // Order stays saved for next sync return new EnrollmentResult { CARequestID = orderIdentifier, Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Could not retrieve order in allowed time (order status: {order.Payload?.Status ?? "unknown"}).\n\n{flow.GetSummary()}" + StatusMessage = "Could not retrieve order in allowed time." }; } } catch (Exception ex) { - var detail = DescribeException(ex); _logger.LogError(ex, "❌ Enrollment failed for subject: {Subject}", subject); return new EnrollmentResult { - CARequestID = orderIdentifier, Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Enrollment failed: {detail}\n\n{flow.GetSummary()}" + StatusMessage = ex.Message }; } finally @@ -454,52 +367,6 @@ public async Task Enroll( } } - /// - /// Unwraps aggregate/inner exceptions and produces a concise, operator-friendly description. - /// Prefers the innermost meaningful message; surfaces HTTP status where present; trims long bodies. - /// - internal static string DescribeException(Exception ex) - { - if (ex == null) return "Unknown error"; - - // Walk through AggregateException layers - while (ex is AggregateException agg && agg.InnerExceptions.Count == 1) - { - ex = agg.InnerExceptions[0]; - } - if (ex is AggregateException aggMulti) - { - var inners = aggMulti.InnerExceptions.Select(e => DescribeException(e)); - return "Multiple errors: " + string.Join(" | ", inners); - } - - // Walk through TargetInvocationException / wrappers that only carry an inner - while (ex.InnerException != null && ( - ex is System.Reflection.TargetInvocationException || - ex.GetType() == typeof(Exception))) - { - ex = ex.InnerException; - } - - var message = ex.Message ?? ex.GetType().Name; - - // Attach HTTP status/body context if this is an HttpRequestException (or wraps one) - var http = ex as HttpRequestException ?? ex.InnerException as HttpRequestException; - if (http != null) - { - var httpMsg = http.Message ?? ""; - if (!ReferenceEquals(http, ex)) - message = $"{message} [{httpMsg}]"; - } - - // Trim anything excessively long so the breadcrumb summary stays readable - const int maxLen = 400; - if (message.Length > maxLen) - message = message.Substring(0, maxLen) + "…"; - - return $"{ex.GetType().Name}: {message}"; - } - /// @@ -634,7 +501,7 @@ private List ExtractDomainsFromCsr(byte[] csrBytes) /// /// Processes ACME authorizations with DNS verification before challenge submission /// - private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails order, AcmeClientConfig config, FlowLogger flow) + private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails order, AcmeClientConfig config, List createdRecords) { if (order?.Payload is not Order payload || payload.Authorizations == null) { @@ -642,195 +509,115 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord } var dnsVerifier = new DnsVerificationHelper(_logger, config.DnsVerificationServer); - var cnameResolver = new CnameResolver(_logger, config.DnsVerificationServer); - var pendingChallenges = new List<(Authorization authz, Challenge challenge, Dns01ChallengeValidationDetails validation, IDomainValidator validator, string recordName)>(); + var pendingChallenges = new List<(Authorization authz, Challenge challenge, Dns01ChallengeValidationDetails validation)>(); - flow.Branch("StageDnsRecords"); - try + // First pass: Create all DNS records + foreach (var authzUrl in payload.Authorizations) { - foreach (var authzUrl in payload.Authorizations) - { - var authz = await acmeClient.GetAuthorizationAsync(authzUrl); - var domain = authz.Identifier.Value; - - if (authz.Status == "valid") - { - flow.Skip($"Stage:{domain}", "authorization already valid (cached)"); - _logger.LogInformation("Using cached authorization for {Domain}", domain); - continue; - } - - var challenge = authz.Challenges.FirstOrDefault(c => c.Type == DNS_CHALLENGE_TYPE); - if (challenge == null) - { - flow.Fail($"Stage:{domain}", $"{DNS_CHALLENGE_TYPE} challenge not available"); - throw new InvalidOperationException($"{DNS_CHALLENGE_TYPE} challenge not available for {domain}"); - } + var authz = await acmeClient.GetAuthorizationAsync(authzUrl); - var validation = acmeClient.DecodeChallengeValidation(authz, challenge) as Dns01ChallengeValidationDetails; - if (validation == null) - { - flow.Fail($"Stage:{domain}", $"failed to decode {DNS_CHALLENGE_TYPE} challenge"); - throw new InvalidOperationException($"Failed to decode {DNS_CHALLENGE_TYPE} challenge validation details for {domain}"); - } + // Skip if authorization is already valid (cached) + if (authz.Status == "valid") + { + _logger.LogInformation("Using cached authorization for {Domain}", authz.Identifier.Value); + continue; + } - // Follow any chain of CNAME delegations to find the name the TXT record must - // actually be created on. A CNAME cannot coexist with a TXT record at the same - // name (RFC 1034), so a delegated challenge name is resolved to its terminal - // target. Returns the original name unchanged when no delegation exists. The - // resolved name is then used to select the DNS provider plugin, so a challenge - // delegated into a zone on a different provider is routed to the plugin that - // owns that zone. - var recordName = await flow.StepAsync($"ResolveCname:{domain}", - async () => await cnameResolver.ResolveChallengeTargetAsync(validation.DnsRecordName), - detail: $"from {validation.DnsRecordName}"); - - // Decide which name to resolve the DNS provider plugin against. The framework - // matches validators against the (wildcard) domain patterns configured per - // provider, keyed on the certificate/zone name (e.g. *.keyfactor.ssl4saas.com) - // -- NOT the _acme-challenge record name. So: - // * No delegation -> resolve on the cert domain (www.example.com), exactly - // as before delegation support existed. - // * CNAME delegated -> resolve on the terminal target (host.validation-zone), - // so the provider that owns the delegation zone is chosen. - // Note the resolver returns the original name unchanged when no CNAME exists, - // and that original name still carries the _acme-challenge prefix, which would - // not match the configured wildcard -- hence the explicit branch here. - bool isDelegated = !string.Equals( - recordName?.TrimEnd('.'), - validation.DnsRecordName?.TrimEnd('.'), - StringComparison.OrdinalIgnoreCase); - var validatorLookupName = isDelegated ? recordName : domain; - - var domainValidator = flow.Step($"ResolveValidator:{domain}", - () => - { - var v = _validatorFactory.ResolveDomainValidator(validatorLookupName, DNS_CHALLENGE_TYPE); - if (v == null) - { - throw new InvalidOperationException( - $"Failed to resolve domain validator for '{validatorLookupName}' (challenge for '{domain}'). " + - "Ensure the appropriate DNS provider plugin is deployed and configured for the zone that hosts the (possibly CNAME-delegated) challenge record."); - } - return v; - }); + // Find DNS-01 challenge + var challenge = authz.Challenges.FirstOrDefault(c => c.Type == DNS_CHALLENGE_TYPE); + if (challenge == null) + throw new InvalidOperationException($"{DNS_CHALLENGE_TYPE} challenge not available"); - _logger.LogInformation("Using domain validator: {ValidatorType} resolved on {LookupName} (record: {RecordName}, domain: {Domain}, delegated: {Delegated})", - domainValidator.GetType().Name, validatorLookupName, recordName, domain, isDelegated); + // Decode challenge validation details + var validation = acmeClient.DecodeChallengeValidation(authz, challenge) as Dns01ChallengeValidationDetails; + if (validation == null) + throw new InvalidOperationException($"Failed to decode {DNS_CHALLENGE_TYPE} challenge validation details"); - await flow.StepAsync($"StageValidation:{domain}", - async () => - { - var result = await domainValidator.StageValidation( - recordName, - validation.DnsRecordValue, - CancellationToken.None); + // Create DNS record (will throw exception with details if it fails) + var dnsProvider = DnsProviderFactory.Create(config, _logger); + await dnsProvider.CreateRecordAsync(validation.DnsRecordName, validation.DnsRecordValue); + createdRecords.Add(validation.DnsRecordName); - if (!result.Success) - throw new InvalidOperationException($"Failed to stage DNS validation for {domain}: {result.ErrorMessage}"); - }, - detail: $"{recordName} via {domainValidator.GetType().Name}"); + _logger.LogInformation("Created DNS record {RecordName} for domain {Domain}", + validation.DnsRecordName, authz.Identifier.Value); - pendingChallenges.Add((authz, challenge, validation, domainValidator, recordName)); - } - } - finally - { - flow.EndBranch(); + pendingChallenges.Add((authz, challenge, validation)); } - if (pendingChallenges.Count > 0 && config.DnsPropagationDelaySeconds > 0) + // Second pass: Wait for DNS propagation and submit challenges + foreach (var (authz, challenge, validation) in pendingChallenges) { - await flow.StepAsync("InitialPropagationDelay", - async () => await Task.Delay(TimeSpan.FromSeconds(config.DnsPropagationDelaySeconds)), - detail: $"{config.DnsPropagationDelaySeconds}s"); - } + // Skip external DNS verification for Infoblox since it cannot ping external DNS providers + bool isInfoblox = config.DnsProvider?.Trim().Equals("infoblox", StringComparison.OrdinalIgnoreCase) ?? false; - flow.Branch("VerifyAndSubmit"); - try - { - foreach (var (authz, challenge, validation, validator, recordName) in pendingChallenges) + if (isInfoblox) { - var domain = authz.Identifier.Value; - var validatorTypeName = validator.GetType().Name.ToLowerInvariant(); - bool isPrivateDnsProvider = validatorTypeName.Contains("rfc2136") || validatorTypeName.Contains("infoblox"); + _logger.LogInformation("Skipping external DNS propagation check for Infoblox provider for {Domain}. Adding short delay...", authz.Identifier.Value); + // Add a short delay to allow Infoblox to process the record internally + await Task.Delay(TimeSpan.FromSeconds(5)); + } + else + { + _logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value); - if (isPrivateDnsProvider) - { - await flow.StepAsync($"PrivateDnsSettle:{domain}", - async () => await Task.Delay(TimeSpan.FromSeconds(5)), - detail: $"{validator.GetType().Name} - skipping public DNS check"); - } - else - { - var authServers = await flow.StepAsync($"GetAuthoritativeDns:{domain}", - async () => await dnsVerifier.GetAuthoritativeDnsServersAsync(recordName)); + // Wait for DNS propagation with verification + var propagated = await dnsVerifier.WaitForDnsPropagationAsync( + validation.DnsRecordName, + validation.DnsRecordValue, + minimumServers: 3 // Require at least 3 DNS servers to confirm + ); - if (!authServers.Any()) - { - _logger.LogWarning("Could not find authoritative DNS servers for {RecordName}. This may indicate DNS delegation issues.", recordName); - } + if (!propagated) + { + _logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...", + authz.Identifier.Value); - var propagated = await flow.StepAsync($"AwaitDnsPropagation:{domain}", - async () => await dnsVerifier.WaitForDnsPropagationAsync( - recordName, - validation.DnsRecordValue, - minimumServers: 3), - detail: $"{recordName} across {authServers.Count} authoritative server(s)"); + // Optional: Add a final delay as fallback + await Task.Delay(TimeSpan.FromSeconds(30)); + } + } - if (!propagated) - { - _logger.LogError("DNS record did not propagate to public DNS servers for {Domain}. " + - "Possible causes: 1) DNS zone not properly delegated, 2) NS records not configured, 3) Zone is private not public. " + - "Check that your domain registrar has NS records pointing to your authoritative nameservers.", domain); + // Submit challenge response + _logger.LogInformation("Submitting challenge for {Domain}", authz.Identifier.Value); + await acmeClient.AnswerChallengeAsync(challenge); + } + } - await flow.StepAsync($"FallbackDelay:{domain}", - async () => await Task.Delay(TimeSpan.FromSeconds(60)), - detail: "propagation not verified - 60s fallback; challenge will likely fail"); - } - else - { - await flow.StepAsync($"PropagationSafetyBuffer:{domain}", - async () => await Task.Delay(TimeSpan.FromSeconds(10)), - detail: "10s buffer for ACME resolvers"); - } - } + /// + /// Removes DNS-01 challenge TXT records. Best effort: failures are logged and never + /// affect the enrollment result. + /// + private async Task CleanupDnsRecordsAsync(AcmeClientConfig config, IEnumerable recordNames) + { + // Wildcard and base-domain authorizations share one record name, so delete each name once. + var names = recordNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (names.Count == 0) + return; - await flow.StepAsync($"SubmitChallenge:{domain}", - async () => await acmeClient.AnswerChallengeAsync(challenge), - detail: $"{recordName}={validation.DnsRecordValue}"); - } + IDnsProvider dnsProvider; + try + { + dnsProvider = DnsProviderFactory.Create(config, _logger); } - finally + catch (Exception ex) { - flow.EndBranch(); + _logger.LogWarning(ex, "Could not create DNS provider for challenge record cleanup"); + return; } - flow.Branch("CleanupDnsRecords"); - try + foreach (var name in names) { - foreach (var (authz, challenge, validation, validator, recordName) in pendingChallenges) + try { - var domain = authz.Identifier.Value; - try - { - await validator.CleanupValidation(recordName, CancellationToken.None); - flow.Step($"Cleanup:{domain}", detail: recordName); - _logger.LogInformation("Cleaned up DNS record {RecordName} for domain {Domain}", - recordName, domain); - } - catch (Exception ex) - { - flow.Fail($"Cleanup:{domain}", DescribeException(ex)); - _logger.LogWarning(ex, "Failed to cleanup DNS record {RecordName} for domain {Domain}", - recordName, domain); - // Continue cleanup for other domains even if one fails - } + if (await dnsProvider.DeleteRecordAsync(name)) + _logger.LogInformation("Removed DNS record {RecordName}", name); + else + _logger.LogWarning("DNS record {RecordName} was not removed; it may need manual cleanup", name); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove DNS record {RecordName}; it may need manual cleanup", name); } - } - finally - { - flow.EndBranch(); } } diff --git a/AcmeCaPlugin/AcmeCaPlugin.csproj b/AcmeCaPlugin/AcmeCaPlugin.csproj index e448171..9808b55 100644 --- a/AcmeCaPlugin/AcmeCaPlugin.csproj +++ b/AcmeCaPlugin/AcmeCaPlugin.csproj @@ -1,6 +1,6 @@ - net10.0 + net6.0;net8.0;net10.0 disable disable true @@ -9,21 +9,26 @@ AcmeCaPlugin - + + + + + - - - + + + + + + - diff --git a/AcmeCaPlugin/AcmeCaPluginConfig.cs b/AcmeCaPlugin/AcmeCaPluginConfig.cs index ee32747..b6a0ddb 100644 --- a/AcmeCaPlugin/AcmeCaPluginConfig.cs +++ b/AcmeCaPlugin/AcmeCaPluginConfig.cs @@ -1,4 +1,4 @@ -using Keyfactor.AnyGateway.Extensions; +using Keyfactor.AnyGateway.Extensions; using System.Collections.Generic; namespace Keyfactor.Extensions.CAPlugin.Acme @@ -51,6 +51,37 @@ public static Dictionary GetPluginAnnotations() DefaultValue = "", Type = "Secret" }, + ["DnsProvider"] = new PropertyConfigInfo() + { + Comments = "DNS Provider to use for ACME DNS-01 challenges (options: Google, Cloudflare, AwsRoute53, Azure, Ns1, Rfc2136, Infoblox)", + Hidden = false, + DefaultValue = "Google", + Type = "String" + }, + + // Google DNS + ["Google_ServiceAccountKeyPath"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["Google_ProjectId"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + // Container Deployment ["AccountStoragePath"] = new PropertyConfigInfo() { @@ -60,14 +91,111 @@ public static Dictionary GetPluginAnnotations() Type = "String" }, - // DNS Propagation Settings - ["DnsPropagationDelaySeconds"] = new PropertyConfigInfo() + // Cloudflare DNS + ["Cloudflare_ApiToken"] = new PropertyConfigInfo() + { + Comments = "Cloudflare DNS: API Token only if using Cloudflare DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + + // Azure DNS + ["Azure_ClientId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "Secret" + }, + ["Azure_ClientSecret"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["Azure_SubscriptionId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Azure_TenantId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["AwsRoute53_AccessKey"] = new PropertyConfigInfo() { - Comments = "Time in seconds to wait after creating DNS records before checking propagation. Set to 0 to skip the delay.", + Comments = "Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional)", Hidden = false, - DefaultValue = "60", + DefaultValue = "", + Type = "String" + }, + ["AwsRoute53_SecretKey"] = new PropertyConfigInfo() + { + Comments = "Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + //IBM NS1 DNS + , + ["Ns1_ApiKey"] = new PropertyConfigInfo() + { + Comments = "Ns1 DNS: Api Key only if Using Ns1 DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "String" + }, + + // RFC 2136 Dynamic DNS (BIND/Microsoft DNS) + ["Rfc2136_Server"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: Server hostname or IP address (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Rfc2136_Port"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: Server port (default 53) (Optional)", + Hidden = false, + DefaultValue = "53", Type = "Number" }, + ["Rfc2136_Zone"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: Zone name (e.g., example.com) (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Rfc2136_TsigKeyName"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: TSIG key name for authentication (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Rfc2136_TsigKey"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: TSIG key (base64 encoded) for authentication (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["Rfc2136_TsigAlgorithm"] = new PropertyConfigInfo() + { + Comments = "RFC 2136 DNS: TSIG algorithm (default hmac-sha256) (Optional)", + Hidden = false, + DefaultValue = "hmac-sha256", + Type = "String" + }, // DNS Verification Settings ["DnsVerificationServer"] = new PropertyConfigInfo() @@ -78,6 +206,54 @@ public static Dictionary GetPluginAnnotations() Type = "String" } + //Infoblox DNS + , + ["Infoblox_Host"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Username"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Password"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + + //Infoblox DNS + , + ["Infoblox_Host"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Username"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Infoblox_Password"] = new PropertyConfigInfo() + { + Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + }; } diff --git a/AcmeCaPlugin/AcmeClientConfig.cs b/AcmeCaPlugin/AcmeClientConfig.cs index 9049174..f659c3f 100644 --- a/AcmeCaPlugin/AcmeClientConfig.cs +++ b/AcmeCaPlugin/AcmeClientConfig.cs @@ -1,4 +1,6 @@ -namespace Keyfactor.Extensions.CAPlugin.Acme +using Amazon; + +namespace Keyfactor.Extensions.CAPlugin.Acme { public class AcmeClientConfig { @@ -9,13 +11,49 @@ public class AcmeClientConfig public string EabHmacKey { get; set; } = null; public string SignerEncryptionPhrase{ get; set; } = null; + // Chosen DNS Provider + public string DnsProvider { get; set; } = null; + + // Google Cloud DNS + public string Google_ServiceAccountKeyPath { get; set; } = null; + public string Google_ServiceAccountKeyJson { get; set; } = null; + public string Google_ProjectId { get; set; } = null; + + // Cloudflare DNS + public string Cloudflare_ApiToken { get; set; } = null; + + + // Azure DNS + public string Azure_ClientId { get; set; } = null; + public string Azure_ClientSecret { get; set; } = null; + public string Azure_SubscriptionId { get; set; } = null; + public string Azure_TenantId { get; set; } = null; + + // AWS Route53 + public string AwsRoute53_AccessKey { get; set; } = null; + public string AwsRoute53_SecretKey { get; set; } = null; + + //IBM NS1 DNS Ns1_ApiKey + public string Ns1_ApiKey { get; set; } = null; + // Container Deployment Support public string AccountStoragePath { get; set; } = null; + // RFC 2136 Dynamic DNS (BIND) + public string Rfc2136_Server { get; set; } = null; + public int Rfc2136_Port { get; set; } = 53; + public string Rfc2136_Zone { get; set; } = null; + public string Rfc2136_TsigKeyName { get; set; } = null; + public string Rfc2136_TsigKey { get; set; } = null; + public string Rfc2136_TsigAlgorithm { get; set; } = "hmac-sha256"; - // DNS Propagation Delay (in seconds) - wait this long after creating DNS records before checking propagation - public int DnsPropagationDelaySeconds { get; set; } = 60; + // Infoblox DNS + public string Infoblox_Host { get; set; } = null; + public string Infoblox_Username { get; set; } = null; + public string Infoblox_Password { get; set; } = null; + public string Infoblox_WapiVersion { get; set; } = "2.12"; + public bool Infoblox_IgnoreSslErrors { get; set; } = false; - // DNS Verification Settings - DNS server IP for verification (for private zones) + // DNS Verification Settings public string DnsVerificationServer { get; set; } = null; } diff --git a/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs new file mode 100644 index 0000000..c3c9f4e --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +public class CloudflareDnsProvider : IDnsProvider +{ + private readonly string _apiToken; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions; + + public CloudflareDnsProvider(string apiToken) + { + _apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken)); + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") + }; + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiToken); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task CreateRecordAsync(string recordName, string txtValue) + { + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + if (zoneId == null || zoneName == null) return false; + + var relativeName = GetRelativeRecordName(recordName, zoneName); + + var payload = new + { + type = "TXT", + name = relativeName, + content = txtValue, + ttl = 1 + }; + + // Manual JSON serialization instead of PostAsJsonAsync + var json = JsonSerializer.Serialize(payload, _jsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"zones/{zoneId}/dns_records", content); + var result = await response.Content.ReadAsStringAsync(); + + Console.WriteLine($"Create TXT: {response.StatusCode} - {result}"); + return response.IsSuccessStatusCode; + } + + public async Task DeleteRecordAsync(string recordName) + { + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + if (zoneId == null || zoneName == null) return false; + + // Cloudflare's name filter matches the fully-qualified record name, not the zone-relative one + var fullName = Uri.EscapeDataString(recordName.TrimEnd('.')); + + var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={fullName}"); + if (!recordsResp.IsSuccessStatusCode) return false; + + var json = await recordsResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var resultArray = doc.RootElement.GetProperty("result"); + if (resultArray.GetArrayLength() == 0) return false; + + // A wildcard and its base domain publish two TXT values under the same name; remove all of them + var allDeleted = true; + foreach (var record in resultArray.EnumerateArray()) + { + var recordId = record.GetProperty("id").GetString(); + if (recordId == null) + { + allDeleted = false; + continue; + } + + var deleteResp = await _httpClient.DeleteAsync($"zones/{zoneId}/dns_records/{recordId}"); + var result = await deleteResp.Content.ReadAsStringAsync(); + + Console.WriteLine($"Delete TXT: {deleteResp.StatusCode} - {result}"); + allDeleted &= deleteResp.IsSuccessStatusCode; + } + + return allDeleted; + } + + private async Task GetZoneIdAsync(string zoneName) + { + var response = await _httpClient.GetAsync($"zones?name={zoneName}"); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var resultArray = doc.RootElement.GetProperty("result"); + if (resultArray.GetArrayLength() == 0) return null; + return resultArray[0].GetProperty("id").GetString(); + } + + private async Task<(string? zoneName, string? zoneId)> FindZoneForRecordAsync(string recordName) + { + if (string.IsNullOrWhiteSpace(recordName)) + return (null, null); + + var parts = recordName.TrimEnd('.').Split('.'); + + // Try progressively shorter domain parts to find the actual zone + // e.g., for "_acme-challenge.www.keyfactor.ssl4saas.com", try: + // - www.keyfactor.ssl4saas.com + // - keyfactor.ssl4saas.com + // - ssl4saas.com + for (int i = 1; i < parts.Length - 1; i++) + { + var candidateZone = string.Join(".", parts.Skip(i)); + var zoneId = await GetZoneIdAsync(candidateZone); + if (zoneId != null) + { + Console.WriteLine($"Found zone: {candidateZone} (id: {zoneId})"); + return (candidateZone, zoneId); + } + } + + return (null, null); + } + + private string GetRelativeRecordName(string recordName, string zoneName) + { + var cleanName = recordName.TrimEnd('.'); + var cleanZone = zoneName.TrimEnd('.'); + + // The recordName should be something like "_acme-challenge.www.keyfactorcloudflareacme.com" + // We need to return the name relative to the zone + + // If the record name ends with the zone name, remove the zone suffix + if (cleanName.EndsWith("." + cleanZone, StringComparison.OrdinalIgnoreCase)) + { + // Remove the zone suffix, keeping the subdomain part + var relativePart = cleanName.Substring(0, cleanName.Length - cleanZone.Length - 1); + return relativePart; + } + else if (cleanName.Equals(cleanZone, StringComparison.OrdinalIgnoreCase)) + { + // If the record name is exactly the zone name, it's the root + return "@"; + } + + // If we can't determine the relative name, return as-is + return cleanName; + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs new file mode 100644 index 0000000..b3419e4 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using System; + +namespace Keyfactor.Extensions.CAPlugin.Acme +{ + public static class DnsProviderFactory + { + public static IDnsProvider Create(AcmeClientConfig config, ILogger logger) + { + if (config == null || string.IsNullOrWhiteSpace(config.DnsProvider)) + throw new ArgumentException("DNS provider type is missing in config."); + + switch (config.DnsProvider.Trim().ToLowerInvariant()) + { + case "google": + return new GoogleDnsProvider( + config.Google_ServiceAccountKeyPath, + config.Google_ServiceAccountKeyJson, + config.Google_ProjectId + ); + + case "cloudflare": + return new CloudflareDnsProvider( + config.Cloudflare_ApiToken + ); + + case "azure": + return new AzureDnsProvider( + config.Azure_TenantId, + config.Azure_ClientId, + config.Azure_ClientSecret, + config.Azure_SubscriptionId + ); + case "awsroute53": + return new AwsRoute53DnsProvider( + config.AwsRoute53_AccessKey, + config.AwsRoute53_SecretKey + ); + case "ns1": + return new Ns1DnsProvider( + config.Ns1_ApiKey + ); + case "rfc2136": + return new Rfc2136DnsProvider( + config.Rfc2136_Server, + config.Rfc2136_Zone, + config.Rfc2136_TsigKeyName, + config.Rfc2136_TsigKey, + config.Rfc2136_TsigAlgorithm, + config.Rfc2136_Port, + logger + ); + case "infoblox": + return new InfobloxDnsProvider( + config.Infoblox_Host, + config.Infoblox_Username, + config.Infoblox_Password, + config.Infoblox_WapiVersion, + config.Infoblox_IgnoreSslErrors, + logger + ); + default: + throw new NotSupportedException($"DNS provider '{config.DnsProvider}' is not supported."); + } + } + } +} diff --git a/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs new file mode 100644 index 0000000..951630f --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs @@ -0,0 +1,191 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Dns.v1; +using Google.Apis.Dns.v1.Data; +using Google.Apis.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +/// +/// Google Cloud DNS provider implementation for managing DNS TXT records. +/// Supports explicit Service Account key (file or JSON), or Workload Identity (Application Default Credentials). +/// +public class GoogleDnsProvider : IDnsProvider +{ + private readonly DnsService _dnsService; + private readonly string _projectId; + + /// + /// Initializes a new instance of the GoogleDnsProvider class. + /// Credential resolution order: JSON key > File path > Application Default Credentials. + /// + /// Path to the Service Account JSON key file (optional) + /// Service Account JSON key as a string (optional, for containerized deployments) + /// Google Cloud project ID containing the DNS zones + public GoogleDnsProvider(string? serviceAccountKeyPath, string? serviceAccountKeyJson, string projectId) + { + _projectId = projectId; + + GoogleCredential credential; + + if (!string.IsNullOrWhiteSpace(serviceAccountKeyJson)) + { + // JSON key provided directly (for container deployments) + Console.WriteLine("✅ Using Service Account JSON key from configuration."); + credential = GoogleCredential.FromJson(serviceAccountKeyJson); + } + else if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath)) + { + Console.WriteLine("✅ Using Service Account JSON key from file."); + credential = GoogleCredential.FromFile(serviceAccountKeyPath); + } + else + { + Console.WriteLine("✅ Using Google Application Default Credentials (Workload Identity if on GCP)."); + credential = GoogleCredential.GetApplicationDefault(); + } + + _dnsService = new DnsService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Keyfactor-AcmeClient" + }); + } + + /// + /// Creates a new TXT record. Alias for UpsertRecordAsync. + /// + public async Task CreateRecordAsync(string recordName, string txtValue) + => await UpsertRecordAsync(recordName, txtValue); + + /// + /// Creates or updates a TXT record in Google Cloud DNS. + /// If the record already exists, it will be replaced with the new value. + /// + public async Task UpsertRecordAsync(string recordName, string txtValue) + { + try + { + var zone = await GetZone(recordName); + if (zone == null) + { + Console.WriteLine($"❌ No zone found for record: {recordName}"); + return false; + } + + var formattedName = EnsureTrailingDot(recordName); + + // Get current records + var rrsetsRequest = _dnsService.ResourceRecordSets.List(_projectId, zone.Name); + var rrsets = await rrsetsRequest.ExecuteAsync(); + + var existing = rrsets.Rrsets?.FirstOrDefault(r => + r.Type == "TXT" && r.Name.TrimEnd('.') == recordName.TrimEnd('.')); + + var newRrset = new ResourceRecordSet + { + Name = formattedName, + Type = "TXT", + Ttl = 60, + Rrdatas = new List { $"\"{txtValue}\"" } + }; + + var change = new Change(); + + if (existing != null) + { + Console.WriteLine($"🔄 TXT record already exists. Replacing value for {recordName}."); + change.Deletions = new List { existing }; + } + + change.Additions = new List { newRrset }; + + var changeRequest = _dnsService.Changes.Create(change, _projectId, zone.Name); + await changeRequest.ExecuteAsync(); + + Console.WriteLine($"✅ Successfully upserted TXT record for {recordName}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error upserting TXT record for {recordName}: {ex}"); + return false; + } + } + + /// + /// Deletes a TXT record from Google Cloud DNS. + /// + public async Task DeleteRecordAsync(string recordName) + { + try + { + var zone = await GetZone(recordName); + if (zone == null) + { + Console.WriteLine($"❌ No zone found for record: {recordName}"); + return false; + } + + var formattedName = EnsureTrailingDot(recordName); + + var rrsetsRequest = _dnsService.ResourceRecordSets.List(_projectId, zone.Name); + var rrsets = await rrsetsRequest.ExecuteAsync(); + + var match = rrsets.Rrsets?.FirstOrDefault(r => + r.Type == "TXT" && r.Name.TrimEnd('.') == recordName.TrimEnd('.')); + + if (match == null) + { + Console.WriteLine($"⚠️ TXT record not found for deletion: {recordName}"); + return false; + } + + var change = new Change + { + Deletions = new List { match } + }; + + var deleteRequest = _dnsService.Changes.Create(change, _projectId, zone.Name); + await deleteRequest.ExecuteAsync(); + + Console.WriteLine($"✅ Successfully deleted TXT record for {recordName}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error deleting TXT record for {recordName}: {ex}"); + return false; + } + } + + /// + /// Finds the appropriate DNS zone for a given record name. + /// + private async Task GetZone(string recordName) + { + try + { + var zonesRequest = _dnsService.ManagedZones.List(_projectId); + var zonesResponse = await zonesRequest.ExecuteAsync(); + var zones = zonesResponse.ManagedZones; + + return zones? + .Where(z => recordName.EndsWith(z.DnsName.TrimEnd('.'))) + .OrderByDescending(z => z.DnsName.Length) + .FirstOrDefault(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error fetching DNS zones: {ex}"); + return null; + } + } + + /// + /// Ensures record name is fully qualified (with trailing dot). + /// + private static string EnsureTrailingDot(string name) + => name.EndsWith(".") ? name : name + "."; +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3346fbf..c0244aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ -# v2.0.0 -* Dns Plugin Support -* Cname Proxy Support - +# v1.3.2 +* Fixed DNS-01 challenge TXT records not being removed after certificate issuance +* Fixed Cloudflare DNS record deletion lookup and removal of all matching TXT records + # v1.3.0 * Containerization Changes for SaaS Environment * Fixed URL CaId Length issue diff --git a/README.md b/README.md index a37c174..0365c9c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,6 +33,7 @@

+ The **Keyfactor ACME CA Gateway Plugin** enables certificate enrollment using the [ACME protocol (RFC 8555)](https://datatracker.ietf.org/doc/html/rfc8555), providing automated certificate issuance via any compliant Certificate Authority. This plugin is designed for **enrollment-only workflows** — it **does not support synchronization or revocation** of certificates. ### 🔧 What It Does @@ -52,14 +53,17 @@ This plugin has been tested and confirmed to work with the following ACME provid It is designed to be provider-agnostic and should work with any standards-compliant ACME server. -### 🌐 DNS Providers (Pluggable) -DNS-01 challenge automation is handled by **separate, pluggable DNS provider plugins** that are deployed alongside the AnyCA Gateway — they are no longer built into this plugin. The Gateway resolves the appropriate DNS provider plugin per domain at enrollment time. - -For the current list of available DNS provider plugins, see the Keyfactor GitHub organization: - -👉 **[Keyfactor DNS provider plugins (`-dnsplugin`)](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin)** +### 🌐 Supported DNS Providers +DNS-01 challenge automation is supported through the following providers: +- **Google Cloud DNS** +- **AWS Route 53** +- **Azure DNS** +- **Cloudflare** +- **NS1** +- **Infoblox** +- **RFC 2136 Dynamic DNS** (BIND with TSIG authentication) -Each plugin repository documents its own supported authentication methods and configuration keys. New DNS providers can be added by publishing a new plugin that implements the Gateway's `IDomainValidator` interface — no change to this ACME plugin is required. +Additional DNS providers can be added by extending the included `IDnsProvider` interface. --- @@ -70,10 +74,9 @@ Each plugin repository documents its own supported authentication methods and co 2. Plugin initializes ACME client and creates a new order. 3. For each domain: a. Retrieve DNS-01 challenge. - b. Resolve any CNAME delegation: follow the CNAME chain from `_acme-challenge.` to its terminal target (see CNAME Delegation below). - c. Select the DNS provider plugin for the zone that owns the (resolved) record name and publish the challenge TXT record there. - d. Wait for DNS propagation and validate record. - e. Notify ACME provider to trigger validation. + b. Use the configured DNS provider to publish challenge record. + c. Wait for DNS propagation and validate record. + d. Notify ACME provider to trigger validation. 4. Once all challenges are valid, finalize the order using CSR. 5. Download the signed certificate from ACME provider. 6. Return PEM certificate to the Gateway. @@ -85,10 +88,10 @@ The plugin uses a modular design that separates ACME communication logic and DNS ## Compatibility -The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later. +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. ## Support -The Acme AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. +The Acme AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. @@ -99,15 +102,17 @@ The Acme AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor custo This plugin automates DNS-01 challenges using pluggable DNS provider implementations. These providers create and remove TXT records to prove domain control to ACME servers.
-🔌 Available DNS Provider Plugins - -DNS providers are distributed as **standalone plugins**, each in its own repository, and are deployed alongside the AnyCA Gateway rather than bundled into this ACME plugin. This lets you add or upgrade a DNS provider without rebuilding the ACME plugin. - -For the current, authoritative list of available DNS provider plugins, query the Keyfactor GitHub organization: - -👉 **[github.com/orgs/Keyfactor/repositories?q=-dnsplugin](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin)** - -Each plugin's own repository is the source of truth for its supported authentication methods, required configuration keys, and setup instructions. Configure the DNS provider(s) through the AnyCA Gateway's **Domain Validation** configuration; the Gateway resolves the correct plugin per domain at enrollment time. +✅ Supported DNS Providers + +| Provider | Auth Methods Supported | Config Keys Required | +|--------------|-----------------------------------------------|--------------------------------------------------------| +| Google DNS | Service Account Key (file or JSON), or ADC | `Google_ServiceAccountKeyPath`, `Google_ServiceAccountKeyJson`, `Google_ProjectId` | +| AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | +| Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | +| Cloudflare | API Token | `Cloudflare_ApiToken` | +| NS1 | API Key | `Ns1_ApiKey` | +| Infoblox | Username/Password (Basic Auth) | `Infoblox_Host`, `Infoblox_Username`, `Infoblox_Password` | +| RFC 2136 | TSIG Key (BIND) | `Rfc2136_Server`, `Rfc2136_Zone`, `Rfc2136_TsigKeyName`, `Rfc2136_TsigKey` |
@@ -121,86 +126,133 @@ This logic is handled by the `DnsVerificationHelper` class and ensures a high-co
-🔗 CNAME Delegation (Proxy) Lookup +🔑 Credential Flow -Many organizations do not want ACME automation to hold write access to their production DNS zone. The industry-standard pattern is to **delegate just the ACME challenge name** to a separate, isolated validation zone using a `CNAME` record. The plugin supports this transparently. +Each provider supports multiple credential strategies: -#### Why delegate? +- **Google DNS**: + - ✅ **Service Account Key File** (via `Google_ServiceAccountKeyPath`) + - ✅ **Service Account Key JSON** (via `Google_ServiceAccountKeyJson` - paste JSON directly) + - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) -A `CNAME` at `_acme-challenge.` points challenge validation at another zone. ACME automation then only needs write access to that isolated zone — never the production zone. A `CNAME` also cannot coexist with any other record type at the same name (RFC 1034), so the TXT record **must** be created at the CNAME's target, not at the original challenge name. +- **AWS Route 53**: + - ✅ **Access/Secret Keys** (`AwsRoute53_AccessKey`, `AwsRoute53_SecretKey`) + - ✅ **IAM Role via EC2 Instance Metadata** (no explicit credentials) -#### How the plugin resolves it +- **Azure DNS**: + - ✅ **Client Secret** (explicit `TenantId`, `ClientId`, `ClientSecret`) + - ✅ **Managed Identity** or environment-based credentials via `DefaultAzureCredential` -Before publishing the challenge record, the plugin runs the `CnameResolver`, which: +- **Cloudflare**: + - ✅ **Bearer API Token** for zone-level DNS control -1. Issues a DNS `CNAME` query for `_acme-challenge.`. -2. **Follows the chain to its terminus.** Delegation can be nested multiple levels deep (`A → B → C → …`); the resolver re-queries at each hop and stops only when it reaches a name that has no further `CNAME`. That terminal name is where the TXT record is created. -3. Returns the original name unchanged when **no** `CNAME` exists — so non-delegated domains behave exactly as before (fully backwards compatible). +- **NS1**: + - ✅ **API Key** passed in header `X-NSONE-Key` -Safety guards: the resolver detects loops (a name that reappears in the chain) and enforces a maximum depth of **10 hops**, logging a warning and stopping at the last good name rather than looping forever. +- **Infoblox**: + - ✅ **Username/Password** (Basic Auth via WAPI REST API) + - Optional: `Infoblox_WapiVersion` (defaults to `2.12`) + - Optional: `Infoblox_IgnoreSslErrors` for self-signed certificates -#### Provider selection follows the delegation +- **RFC 2136 (BIND)**: + - ✅ **TSIG Key** for secure dynamic DNS updates + - Supports algorithms: `hmac-md5`, `hmac-sha1`, `hmac-sha256`, `hmac-sha384`, `hmac-sha512` + - Default algorithm: `hmac-sha256` (recommended) + - Optional: `Rfc2136_Port` (defaults to `53`) -The DNS provider plugin is resolved against the **name where the record actually lands**: +
-- **No delegation** → the provider is selected for the certificate domain (e.g. `www.example.com`). -- **Delegated** → the provider is selected for the **resolved terminal target** (e.g. `abc123.acme-validation.net`). +
+🏢 On-Premise DNS (RFC 2136) -This means a challenge delegated into a zone hosted by a *different* DNS provider is routed to the plugin that owns that zone. Propagation checks and cleanup also operate on the resolved name. +The RFC 2136 provider enables ACME DNS-01 challenges with on-premise DNS servers that support dynamic updates, including: -> ℹ️ Provider selection uses the AnyCA Gateway's Domain Validation configuration, which matches a configured (optionally wildcard) domain pattern one label at a time. Ensure the **delegation target's zone** is covered by a Domain Validation entry — e.g. a target of `abc123.acme-validation.net` needs a validator whose domain matches `*.acme-validation.net`. +- **BIND** (Berkeley Internet Name Domain) +- **PowerDNS** (with dynamic update support) +- Any DNS server supporting RFC 2136 with TSIG authentication -#### Example (multi-level delegation across providers) +#### Configuration Requirements -```text -Cert domain: www.example.com (production zone, e.g. GoDaddy) -Challenge name: _acme-challenge.www.example.com - -DNS records (static, created once): - _acme-challenge.www.example.com CNAME hop1.example.com (GoDaddy) - hop1.example.com CNAME hop2.example.com (GoDaddy) - hop2.example.com CNAME val.acme-zone.net (points into the validation zone) - -Resolution + placement: - _acme-challenge.www.example.com → hop1 → hop2 → val.acme-zone.net (terminal) - Provider plugin selected for: acme-zone.net (the zone that owns val.acme-zone.net) - TXT record created at: val.acme-zone.net - ACME CA queries _acme-challenge.www.example.com, follows the CNAMEs, finds the TXT ✅ -``` +| Field | Description | Required | +|-------|-------------|----------| +| `Rfc2136_Server` | DNS server hostname or IP address | ✅ Yes | +| `Rfc2136_Zone` | DNS zone to update (e.g., `example.com`) | ✅ Yes | +| `Rfc2136_TsigKeyName` | TSIG key name (e.g., `acme-update-key`) | ✅ Yes | +| `Rfc2136_TsigKey` | Base64-encoded TSIG secret key | ✅ Yes | +| `Rfc2136_TsigAlgorithm` | TSIG algorithm (default: `hmac-sha256`) | Optional | +| `Rfc2136_Port` | DNS server port (default: `53`) | Optional | +| `DnsVerificationServer` | DNS server IP for verification (for private zones) | Optional | -> ℹ️ **Private/internal delegation zones:** set `DnsVerificationServer` to your authoritative DNS server IP. The `CnameResolver` honors it for the CNAME lookups; otherwise public resolvers (Google, Cloudflare, Quad9) are used. +#### Generating TSIG Keys -
+**For BIND:** +bash -
-🔑 Provider Credentials & Configuration +**Generate a TSIG key using tsig-keygen (BIND 9.10+)** +tsig-keygen -a hmac-sha256 acme-update-key -Authentication methods and required configuration keys are specific to each DNS provider and are **documented in that provider's own plugin repository** — see the [`-dnsplugin` repositories](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin). Credentials and settings are supplied through the AnyCA Gateway's **Domain Validation** configuration for the chosen plugin, not in this ACME plugin's configuration. +**Output example:** +key "acme-update-key" { + algorithm hmac-sha256; + secret "base64encodedkey=="; +}; -
+#### BIND Configuration Example + +Add to `named.conf`: + +key "acme-update-key" { + algorithm hmac-sha256; + secret "YourBase64EncodedKeyHere=="; +}; + +zone "example.com" { + type master; + file "/var/named/example.com.zone"; + allow-update { key "acme-update-key"; }; +}; -
-🏢 On-Premise / Private DNS -On-premise and private DNS support (e.g. RFC 2136 dynamic updates against BIND/PowerDNS with TSIG) is provided by the corresponding DNS provider plugin — see its repository under the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) for TSIG key generation, server/zone settings, and setup examples. +> ⚠️ **Security Note:** TSIG keys should be treated as secrets. Store them securely and use strong keys generated with cryptographically secure random number generators. -> ⚠️ **Private DNS Zones:** For private/local DNS zones (e.g., `.local`) that are not reachable via public resolvers, set `DnsVerificationServer` to your authoritative DNS server IP. This ACME plugin uses it both to verify TXT record propagation and to resolve CNAME delegation chains. +> ⚠️ **Private DNS Zones:** For private/local DNS zones (e.g., `.local`), set `DnsVerificationServer` to your authoritative DNS server IP so the plugin can verify TXT record propagation.
🧩 Adding New DNS Providers -DNS providers are independent plugins, so adding a new one requires **no change to this ACME plugin**. Publish a plugin that implements the AnyCA Gateway's `IDomainValidator` interface (create/cleanup the validation record for a domain), deploy it alongside the Gateway, and configure it under **Domain Validation**. The Gateway will resolve it per domain at enrollment time. +To add support for new DNS services: + +1. Implement the `IDnsProvider` interface: + ```csharp + public interface IDnsProvider + { + Task CreateRecordAsync(string recordName, string txtValue); + Task DeleteRecordAsync(string recordName); + } + ``` + +2. Register the new provider in the `DnsProviderFactory`: + ```csharp + case "yourprovider": + return new YourCustomDnsProvider(config.YourProviderConfigValues...); + ``` + +3. Use zone detection logic similar to `GoogleDnsProvider`, `AzureDnsProvider`, or `Ns1DnsProvider`. -Use any existing plugin in the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) as a reference implementation. +Each provider is instantiated dynamically based on the `DnsProvider` field in the `AcmeClientConfig`. + +> 🔁 This modular DNS system ensures challenge automation works across cloud providers and is easily extensible.
-🔒 Per-Domain DNS Provider Resolution +🔒 CA-Level DNS Provider Binding + +Each ACME/DNS combination is supported **at the CA level**, meaning that only **one DNS provider** is configured per CA entry in Keyfactor. This ensures a clear and isolated challenge path for each ACME CA connector instance. -You can configure **multiple DNS provider plugins** and the Gateway selects the appropriate one for each domain based on your **Domain Validation** configuration (matching a configured, optionally wildcard, domain pattern). This also means a single certificate with SANs across different zones/providers can be validated using different plugins, and CNAME-delegated challenges are routed to the plugin that owns the delegation target's zone (see **CNAME Delegation** above). +If you need to support multiple DNS zones/providers (e.g., both AWS and Cloudflare), configure **separate CA entries**, each with its own DNS provider configuration.
@@ -472,9 +524,13 @@ If `AccountStoragePath` is not set and `%APPDATA%` is unavailable, the plugin de
-🌐 DNS Provider Authentication in Containers +🌐 Google Cloud DNS in Containers -DNS provider credentials in containerized environments are handled by each **DNS provider plugin**, not by this ACME plugin. Options such as cloud-native identity (GKE Workload Identity, EKS IRSA, AKS Pod Identity), mounted key files, or config-supplied secrets depend on the provider — see the relevant plugin under the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) for its supported container authentication methods. +For Google Cloud DNS in container environments, you have three authentication options: + +1. **Workload Identity (GKE)**: No explicit credentials needed; uses pod identity. +2. **JSON key in config**: Paste the service account JSON directly into `Google_ServiceAccountKeyJson`. +3. **Mounted JSON file**: Mount the service account key file and set `Google_ServiceAccountKeyPath`.
@@ -508,15 +564,16 @@ spec: 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [Acme AnyCA Gateway REST plugin](https://github.com/Keyfactor/acme-provider-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net10.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the Acme AnyCA Gateway REST plugin DLLs (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the Acme AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -529,102 +586,123 @@ spec: * **Gateway Registration** Each ACME CA issues certificates that chain to a specific intermediate and root certificate. For trust validation and proper integration with the Keyfactor Gateway, the following steps are required for **every ACME CA** used in your environment. - + --- - + ### 🔍 Retrieving Root and Intermediate Certificates - + Here is how to obtain the root and intermediate CA certificates from supported ACME providers: - + #### Let's Encrypt - + Let's Encrypt periodically rotates its root and intermediate certificates. Always refer to their official certificates page for the current active chain. - + **How to Get:** - Browse to: https://letsencrypt.org/certificates/ - Identify the currently active **root** and **intermediate** certificates listed on that page. - Download both certificates in **PEM format**. - + #### Google Certificate Authority Service (CAS) - + - **Root** and **Intermediate** are custom per CA Pool. - + **How to Get:** 1. In the [Google Cloud Console](https://console.cloud.google.com/security/privateca), navigate to your CA pool. 2. Click the CA name and go to the **Certificates** tab. 3. Download the **root** and **intermediate** certificates for the issuing CA in PEM format. - + #### ZeroSSL - + - **Root**: USERTrust RSA Certification Authority - **Intermediate**: ZeroSSL RSA Domain Secure Site CA - + **How to Get:** - Visit: https://zerossl.com - Download the full certificate chain in PEM format. - Extract individual certs if needed using OpenSSL or a text editor. - + #### Buypass - + - **Root**: Buypass Class 3 Root CA - **Intermediate**: Buypass Class 3 CA 1 / G2 (depends on issuance) - + **How to Get:** - Go to: https://www.buypass.com - Download both root and intermediate in PEM or DER format. - + --- - + ### 🧩 Installing Certificates on the Keyfactor Gateway Server - + Once downloaded, the **root and intermediate certificates must be installed** in the proper Windows certificate stores on the Gateway server. - + #### Steps: - + 1. **Open** `certlm.msc` (Local Computer Certificates) 2. Install the **Root CA certificate** into: - `Trusted Root Certification Authorities` → `Certificates` 3. Install the **Intermediate CA certificate** into: - `Intermediate Certification Authorities` → `Certificates` - + You can import certificates using the GUI or PowerShell: - + ```powershell Import-Certificate -FilePath "C:\path\to\intermediate.crt" -CertStoreLocation "Cert:\LocalMachine\CA" Import-Certificate -FilePath "C:\path\to\root.crt" -CertStoreLocation "Cert:\LocalMachine\Root" ``` - + --- - + ### 🔑 Using the Intermediate Thumbprint - + When registering a new CA in Keyfactor Command: - + - You must specify the **thumbprint** of the Intermediate CA certificate. - This is used to associate issued certificates with the correct issuing chain. - + **How to Get the Thumbprint:** - + 1. In `certlm.msc`, open the certificate under **Intermediate Certification Authorities**. 2. Go to **Details** tab → Scroll to **Thumbprint**. 3. Copy the hex string (ignore spaces). - + --- - + ⚠️ All certificate chains must be trusted by the Gateway OS. If the intermediate is missing or untrusted, issuance will fail or returned certificates may not chain properly. * **CA Connection** Populate using the configuration fields collected in the [requirements](#requirements) section. - * **Enabled** - Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped. - * **DirectoryUrl** - ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.) - * **Email** - Email for ACME account registration. - * **EabKid** - External Account Binding Key ID (optional) - * **EabHmacKey** - External Account Binding HMAC key (optional) - * **SignerEncryptionPhrase** - Used to encrypt singer information when account is saved to disk (optional) - * **AccountStoragePath** - Path for ACME account storage. Defaults to %APPDATA%\AcmeAccounts on Windows or ./AcmeAccounts in containers. - * **DnsVerificationServer** - DNS server used to verify TXT record propagation and to resolve CNAME delegation chains. For private/local DNS zones, set this to your authoritative DNS server IP (e.g., 10.3.10.37). Leave empty to use public DNS servers (Google, Cloudflare, etc.). + * **Enabled** - Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped. + * **DirectoryUrl** - ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.) + * **Email** - Email for ACME account registration. + * **EabKid** - External Account Binding Key ID (optional) + * **EabHmacKey** - External Account Binding HMAC key (optional) + * **SignerEncryptionPhrase** - Used to encrypt singer information when account is saved to disk (optional) + * **DnsProvider** - DNS Provider to use for ACME DNS-01 challenges (options: Google, Cloudflare, AwsRoute53, Azure, Ns1, Rfc2136, Infoblox) + * **Google_ServiceAccountKeyPath** - Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional) + * **Google_ServiceAccountKeyJson** - Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments) + * **Google_ProjectId** - Google Cloud DNS: Project ID only if using Google DNS (Optional) + * **AccountStoragePath** - Path for ACME account storage. Defaults to %APPDATA%\AcmeAccounts on Windows or ./AcmeAccounts in containers. + * **Cloudflare_ApiToken** - Cloudflare DNS: API Token only if using Cloudflare DNS (Optional) + * **Azure_ClientId** - Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_ClientSecret** - Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_SubscriptionId** - Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_TenantId** - Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **AwsRoute53_AccessKey** - Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional) + * **AwsRoute53_SecretKey** - Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional) + * **Ns1_ApiKey** - Ns1 DNS: Api Key only if Using Ns1 DNS (Optional) + * **Rfc2136_Server** - RFC 2136 DNS: Server hostname or IP address (Optional) + * **Rfc2136_Port** - RFC 2136 DNS: Server port (default 53) (Optional) + * **Rfc2136_Zone** - RFC 2136 DNS: Zone name (e.g., example.com) (Optional) + * **Rfc2136_TsigKeyName** - RFC 2136 DNS: TSIG key name for authentication (Optional) + * **Rfc2136_TsigKey** - RFC 2136 DNS: TSIG key (base64 encoded) for authentication (Optional) + * **Rfc2136_TsigAlgorithm** - RFC 2136 DNS: TSIG algorithm (default hmac-sha256) (Optional) + * **DnsVerificationServer** - DNS server to use for verifying TXT record propagation. For private/local DNS zones, set this to your authoritative DNS server IP (e.g., 10.3.10.37). Leave empty to use public DNS servers (Google, Cloudflare, etc.). + * **Infoblox_Host** - Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional) + * **Infoblox_Username** - Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional) + * **Infoblox_Password** - Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional) 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The Acme plugin supports the following product IDs: @@ -632,9 +710,11 @@ spec: 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. + ## Compatibility -The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later. +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. + ## License @@ -642,4 +722,4 @@ Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file diff --git a/TestProgram/TestProgram.csproj b/TestProgram/TestProgram.csproj index 4b138b6..9e8374a 100644 --- a/TestProgram/TestProgram.csproj +++ b/TestProgram/TestProgram.csproj @@ -2,19 +2,18 @@ Exe - net10.0 + net6.0;net8.0;net10.0 enable enable - + - - - - + + + diff --git a/docsource/configuration.md b/docsource/configuration.md index 9a92d95..186f285 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -18,14 +18,17 @@ This plugin has been tested and confirmed to work with the following ACME provid It is designed to be provider-agnostic and should work with any standards-compliant ACME server. -### 🌐 DNS Providers (Pluggable) -DNS-01 challenge automation is handled by **separate, pluggable DNS provider plugins** that are deployed alongside the AnyCA Gateway — they are no longer built into this plugin. The Gateway resolves the appropriate DNS provider plugin per domain at enrollment time. - -For the current list of available DNS provider plugins, see the Keyfactor GitHub organization: - -👉 **[Keyfactor DNS provider plugins (`-dnsplugin`)](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin)** - -Each plugin repository documents its own supported authentication methods and configuration keys. New DNS providers can be added by publishing a new plugin that implements the Gateway's `IDomainValidator` interface — no change to this ACME plugin is required. +### 🌐 Supported DNS Providers +DNS-01 challenge automation is supported through the following providers: +- **Google Cloud DNS** +- **AWS Route 53** +- **Azure DNS** +- **Cloudflare** +- **NS1** +- **Infoblox** +- **RFC 2136 Dynamic DNS** (BIND with TSIG authentication) + +Additional DNS providers can be added by extending the included `IDnsProvider` interface. --- @@ -36,10 +39,9 @@ Each plugin repository documents its own supported authentication methods and co 2. Plugin initializes ACME client and creates a new order. 3. For each domain: a. Retrieve DNS-01 challenge. - b. Resolve any CNAME delegation: follow the CNAME chain from `_acme-challenge.` to its terminal target (see CNAME Delegation below). - c. Select the DNS provider plugin for the zone that owns the (resolved) record name and publish the challenge TXT record there. - d. Wait for DNS propagation and validate record. - e. Notify ACME provider to trigger validation. + b. Use the configured DNS provider to publish challenge record. + c. Wait for DNS propagation and validate record. + d. Notify ACME provider to trigger validation. 4. Once all challenges are valid, finalize the order using CSR. 5. Download the signed certificate from ACME provider. 6. Return PEM certificate to the Gateway. @@ -51,7 +53,7 @@ The plugin uses a modular design that separates ACME communication logic and DNS ## Compatibility -The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later. +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. ## Requirements @@ -61,15 +63,17 @@ The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gatewa This plugin automates DNS-01 challenges using pluggable DNS provider implementations. These providers create and remove TXT records to prove domain control to ACME servers.
-🔌 Available DNS Provider Plugins - -DNS providers are distributed as **standalone plugins**, each in its own repository, and are deployed alongside the AnyCA Gateway rather than bundled into this ACME plugin. This lets you add or upgrade a DNS provider without rebuilding the ACME plugin. - -For the current, authoritative list of available DNS provider plugins, query the Keyfactor GitHub organization: - -👉 **[github.com/orgs/Keyfactor/repositories?q=-dnsplugin](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin)** - -Each plugin's own repository is the source of truth for its supported authentication methods, required configuration keys, and setup instructions. Configure the DNS provider(s) through the AnyCA Gateway's **Domain Validation** configuration; the Gateway resolves the correct plugin per domain at enrollment time. +✅ Supported DNS Providers + +| Provider | Auth Methods Supported | Config Keys Required | +|--------------|-----------------------------------------------|--------------------------------------------------------| +| Google DNS | Service Account Key (file or JSON), or ADC | `Google_ServiceAccountKeyPath`, `Google_ServiceAccountKeyJson`, `Google_ProjectId` | +| AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | +| Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | +| Cloudflare | API Token | `Cloudflare_ApiToken` | +| NS1 | API Key | `Ns1_ApiKey` | +| Infoblox | Username/Password (Basic Auth) | `Infoblox_Host`, `Infoblox_Username`, `Infoblox_Password` | +| RFC 2136 | TSIG Key (BIND) | `Rfc2136_Server`, `Rfc2136_Zone`, `Rfc2136_TsigKeyName`, `Rfc2136_TsigKey` |
@@ -83,86 +87,134 @@ This logic is handled by the `DnsVerificationHelper` class and ensures a high-co
-🔗 CNAME Delegation (Proxy) Lookup +🔑 Credential Flow -Many organizations do not want ACME automation to hold write access to their production DNS zone. The industry-standard pattern is to **delegate just the ACME challenge name** to a separate, isolated validation zone using a `CNAME` record. The plugin supports this transparently. +Each provider supports multiple credential strategies: -#### Why delegate? +- **Google DNS**: + - ✅ **Service Account Key File** (via `Google_ServiceAccountKeyPath`) + - ✅ **Service Account Key JSON** (via `Google_ServiceAccountKeyJson` - paste JSON directly) + - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) -A `CNAME` at `_acme-challenge.` points challenge validation at another zone. ACME automation then only needs write access to that isolated zone — never the production zone. A `CNAME` also cannot coexist with any other record type at the same name (RFC 1034), so the TXT record **must** be created at the CNAME's target, not at the original challenge name. +- **AWS Route 53**: + - ✅ **Access/Secret Keys** (`AwsRoute53_AccessKey`, `AwsRoute53_SecretKey`) + - ✅ **IAM Role via EC2 Instance Metadata** (no explicit credentials) -#### How the plugin resolves it +- **Azure DNS**: + - ✅ **Client Secret** (explicit `TenantId`, `ClientId`, `ClientSecret`) + - ✅ **Managed Identity** or environment-based credentials via `DefaultAzureCredential` -Before publishing the challenge record, the plugin runs the `CnameResolver`, which: +- **Cloudflare**: + - ✅ **Bearer API Token** for zone-level DNS control -1. Issues a DNS `CNAME` query for `_acme-challenge.`. -2. **Follows the chain to its terminus.** Delegation can be nested multiple levels deep (`A → B → C → …`); the resolver re-queries at each hop and stops only when it reaches a name that has no further `CNAME`. That terminal name is where the TXT record is created. -3. Returns the original name unchanged when **no** `CNAME` exists — so non-delegated domains behave exactly as before (fully backwards compatible). +- **NS1**: + - ✅ **API Key** passed in header `X-NSONE-Key` -Safety guards: the resolver detects loops (a name that reappears in the chain) and enforces a maximum depth of **10 hops**, logging a warning and stopping at the last good name rather than looping forever. +- **Infoblox**: + - ✅ **Username/Password** (Basic Auth via WAPI REST API) + - Optional: `Infoblox_WapiVersion` (defaults to `2.12`) + - Optional: `Infoblox_IgnoreSslErrors` for self-signed certificates -#### Provider selection follows the delegation +- **RFC 2136 (BIND)**: + - ✅ **TSIG Key** for secure dynamic DNS updates + - Supports algorithms: `hmac-md5`, `hmac-sha1`, `hmac-sha256`, `hmac-sha384`, `hmac-sha512` + - Default algorithm: `hmac-sha256` (recommended) + - Optional: `Rfc2136_Port` (defaults to `53`) -The DNS provider plugin is resolved against the **name where the record actually lands**: +
-- **No delegation** → the provider is selected for the certificate domain (e.g. `www.example.com`). -- **Delegated** → the provider is selected for the **resolved terminal target** (e.g. `abc123.acme-validation.net`). +
+🏢 On-Premise DNS (RFC 2136) -This means a challenge delegated into a zone hosted by a *different* DNS provider is routed to the plugin that owns that zone. Propagation checks and cleanup also operate on the resolved name. +The RFC 2136 provider enables ACME DNS-01 challenges with on-premise DNS servers that support dynamic updates, including: -> ℹ️ Provider selection uses the AnyCA Gateway's Domain Validation configuration, which matches a configured (optionally wildcard) domain pattern one label at a time. Ensure the **delegation target's zone** is covered by a Domain Validation entry — e.g. a target of `abc123.acme-validation.net` needs a validator whose domain matches `*.acme-validation.net`. +- **BIND** (Berkeley Internet Name Domain) +- **PowerDNS** (with dynamic update support) +- Any DNS server supporting RFC 2136 with TSIG authentication -#### Example (multi-level delegation across providers) +#### Configuration Requirements -```text -Cert domain: www.example.com (production zone, e.g. GoDaddy) -Challenge name: _acme-challenge.www.example.com - -DNS records (static, created once): - _acme-challenge.www.example.com CNAME hop1.example.com (GoDaddy) - hop1.example.com CNAME hop2.example.com (GoDaddy) - hop2.example.com CNAME val.acme-zone.net (points into the validation zone) - -Resolution + placement: - _acme-challenge.www.example.com → hop1 → hop2 → val.acme-zone.net (terminal) - Provider plugin selected for: acme-zone.net (the zone that owns val.acme-zone.net) - TXT record created at: val.acme-zone.net - ACME CA queries _acme-challenge.www.example.com, follows the CNAMEs, finds the TXT ✅ -``` +| Field | Description | Required | +|-------|-------------|----------| +| `Rfc2136_Server` | DNS server hostname or IP address | ✅ Yes | +| `Rfc2136_Zone` | DNS zone to update (e.g., `example.com`) | ✅ Yes | +| `Rfc2136_TsigKeyName` | TSIG key name (e.g., `acme-update-key`) | ✅ Yes | +| `Rfc2136_TsigKey` | Base64-encoded TSIG secret key | ✅ Yes | +| `Rfc2136_TsigAlgorithm` | TSIG algorithm (default: `hmac-sha256`) | Optional | +| `Rfc2136_Port` | DNS server port (default: `53`) | Optional | +| `DnsVerificationServer` | DNS server IP for verification (for private zones) | Optional | -> ℹ️ **Private/internal delegation zones:** set `DnsVerificationServer` to your authoritative DNS server IP. The `CnameResolver` honors it for the CNAME lookups; otherwise public resolvers (Google, Cloudflare, Quad9) are used. +#### Generating TSIG Keys -
+**For BIND:** +bash -
-🔑 Provider Credentials & Configuration +**Generate a TSIG key using tsig-keygen (BIND 9.10+)** +tsig-keygen -a hmac-sha256 acme-update-key -Authentication methods and required configuration keys are specific to each DNS provider and are **documented in that provider's own plugin repository** — see the [`-dnsplugin` repositories](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin). Credentials and settings are supplied through the AnyCA Gateway's **Domain Validation** configuration for the chosen plugin, not in this ACME plugin's configuration. +**Output example:** +key "acme-update-key" { + algorithm hmac-sha256; + secret "base64encodedkey=="; +}; -
-
-🏢 On-Premise / Private DNS +#### BIND Configuration Example + +Add to `named.conf`: + +key "acme-update-key" { + algorithm hmac-sha256; + secret "YourBase64EncodedKeyHere=="; +}; + +zone "example.com" { + type master; + file "/var/named/example.com.zone"; + allow-update { key "acme-update-key"; }; +}; + -On-premise and private DNS support (e.g. RFC 2136 dynamic updates against BIND/PowerDNS with TSIG) is provided by the corresponding DNS provider plugin — see its repository under the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) for TSIG key generation, server/zone settings, and setup examples. +> ⚠️ **Security Note:** TSIG keys should be treated as secrets. Store them securely and use strong keys generated with cryptographically secure random number generators. -> ⚠️ **Private DNS Zones:** For private/local DNS zones (e.g., `.local`) that are not reachable via public resolvers, set `DnsVerificationServer` to your authoritative DNS server IP. This ACME plugin uses it both to verify TXT record propagation and to resolve CNAME delegation chains. +> ⚠️ **Private DNS Zones:** For private/local DNS zones (e.g., `.local`), set `DnsVerificationServer` to your authoritative DNS server IP so the plugin can verify TXT record propagation.
🧩 Adding New DNS Providers -DNS providers are independent plugins, so adding a new one requires **no change to this ACME plugin**. Publish a plugin that implements the AnyCA Gateway's `IDomainValidator` interface (create/cleanup the validation record for a domain), deploy it alongside the Gateway, and configure it under **Domain Validation**. The Gateway will resolve it per domain at enrollment time. +To add support for new DNS services: -Use any existing plugin in the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) as a reference implementation. +1. Implement the `IDnsProvider` interface: + ```csharp + public interface IDnsProvider + { + Task CreateRecordAsync(string recordName, string txtValue); + Task DeleteRecordAsync(string recordName); + } + ``` + +2. Register the new provider in the `DnsProviderFactory`: + ```csharp + case "yourprovider": + return new YourCustomDnsProvider(config.YourProviderConfigValues...); + ``` + +3. Use zone detection logic similar to `GoogleDnsProvider`, `AzureDnsProvider`, or `Ns1DnsProvider`. + +Each provider is instantiated dynamically based on the `DnsProvider` field in the `AcmeClientConfig`. + +> 🔁 This modular DNS system ensures challenge automation works across cloud providers and is easily extensible.
-🔒 Per-Domain DNS Provider Resolution +🔒 CA-Level DNS Provider Binding + +Each ACME/DNS combination is supported **at the CA level**, meaning that only **one DNS provider** is configured per CA entry in Keyfactor. This ensures a clear and isolated challenge path for each ACME CA connector instance. -You can configure **multiple DNS provider plugins** and the Gateway selects the appropriate one for each domain based on your **Domain Validation** configuration (matching a configured, optionally wildcard, domain pattern). This also means a single certificate with SANs across different zones/providers can be validated using different plugins, and CNAME-delegated challenges are routed to the plugin that owns the delegation target's zone (see **CNAME Delegation** above). +If you need to support multiple DNS zones/providers (e.g., both AWS and Cloudflare), configure **separate CA entries**, each with its own DNS provider configuration.
@@ -434,9 +486,13 @@ If `AccountStoragePath` is not set and `%APPDATA%` is unavailable, the plugin de
-🌐 DNS Provider Authentication in Containers +🌐 Google Cloud DNS in Containers + +For Google Cloud DNS in container environments, you have three authentication options: -DNS provider credentials in containerized environments are handled by each **DNS provider plugin**, not by this ACME plugin. Options such as cloud-native identity (GKE Workload Identity, EKS IRSA, AKS Pod Identity), mounted key files, or config-supplied secrets depend on the provider — see the relevant plugin under the [`-dnsplugin` list](https://github.com/orgs/Keyfactor/repositories?q=-dnsplugin) for its supported container authentication methods. +1. **Workload Identity (GKE)**: No explicit credentials needed; uses pod identity. +2. **JSON key in config**: Paste the service account JSON directly into `Google_ServiceAccountKeyJson`. +3. **Mounted JSON file**: Mount the service account key file and set `Google_ServiceAccountKeyPath`.
diff --git a/integration-manifest.json b/integration-manifest.json index 62482ca..08ba468 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -9,7 +9,7 @@ "support_level": "kf-supported", "link_github": true, "update_catalog": true, - "gateway_framework": "26.2", + "gateway_framework": "24.2", "about": { "carest": { "ca_plugin_config": [ @@ -37,13 +37,97 @@ "name": "SignerEncryptionPhrase", "description": "Used to encrypt singer information when account is saved to disk (optional)" }, + { + "name": "DnsProvider", + "description": "DNS Provider to use for ACME DNS-01 challenges (options: Google, Cloudflare, AwsRoute53, Azure, Ns1, Rfc2136, Infoblox)" + }, + { + "name": "Google_ServiceAccountKeyPath", + "description": "Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional)" + }, + { + "name": "Google_ServiceAccountKeyJson", + "description": "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)" + }, + { + "name": "Google_ProjectId", + "description": "Google Cloud DNS: Project ID only if using Google DNS (Optional)" + }, { "name": "AccountStoragePath", "description": "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers." }, + { + "name": "Cloudflare_ApiToken", + "description": "Cloudflare DNS: API Token only if using Cloudflare DNS (Optional)" + }, + { + "name": "Azure_ClientId", + "description": "Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_ClientSecret", + "description": "Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_SubscriptionId", + "description": "Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_TenantId", + "description": "Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "AwsRoute53_AccessKey", + "description": "Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional)" + }, + { + "name": "AwsRoute53_SecretKey", + "description": "Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional)" + }, + { + "name": "Ns1_ApiKey", + "description": "Ns1 DNS: Api Key only if Using Ns1 DNS (Optional)" + }, + { + "name": "Rfc2136_Server", + "description": "RFC 2136 DNS: Server hostname or IP address (Optional)" + }, + { + "name": "Rfc2136_Port", + "description": "RFC 2136 DNS: Server port (default 53) (Optional)" + }, + { + "name": "Rfc2136_Zone", + "description": "RFC 2136 DNS: Zone name (e.g., example.com) (Optional)" + }, + { + "name": "Rfc2136_TsigKeyName", + "description": "RFC 2136 DNS: TSIG key name for authentication (Optional)" + }, + { + "name": "Rfc2136_TsigKey", + "description": "RFC 2136 DNS: TSIG key (base64 encoded) for authentication (Optional)" + }, + { + "name": "Rfc2136_TsigAlgorithm", + "description": "RFC 2136 DNS: TSIG algorithm (default hmac-sha256) (Optional)" + }, { "name": "DnsVerificationServer", - "description": "DNS server used to verify TXT record propagation and to resolve CNAME delegation chains. For private/local DNS zones, set this to your authoritative DNS server IP (e.g., 10.3.10.37). Leave empty to use public DNS servers (Google, Cloudflare, etc.)." + "description": "DNS server to use for verifying TXT record propagation. For private/local DNS zones, set this to your authoritative DNS server IP (e.g., 10.3.10.37). Leave empty to use public DNS servers (Google, Cloudflare, etc.)." + }, + { + "name": "Infoblox_Host", + "description": "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)" + }, + { + "name": "Infoblox_Username", + "description": "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)" + }, + { + "name": "Infoblox_Password", + "description": "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)" } ], "enrollment_config": [],