diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d2335..c2822c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,6 @@ +v1.0.1 +- Add LuaDnsCnameDomainValidator (validation type "cname") so CAs that require CNAME domain control validation (e.g. CSC Global) can have their record published in LuaDNS +- Share credentials/zone discovery between the TXT (dns-01) and CNAME validators via a common base class; the existing dns-01 validator is unchanged + v1.0.0 - Inital Version diff --git a/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsCnameDomainValidatorTests.cs b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsCnameDomainValidatorTests.cs new file mode 100644 index 0000000..e287adb --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsCnameDomainValidatorTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Text.Json; +using Xunit; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns.Tests +{ + public class LuaDnsCnameDomainValidatorTests + { + private const string Zones = "[{\"id\":42,\"name\":\"example.com.\"}]"; + + [Fact] + public void GetValidationType_ReturnsCname() + { + Assert.Equal("cname", new LuaDnsCnameDomainValidator().GetValidationType()); + } + + [Fact] + public void TxtValidator_StillAdvertisesDns01() + { + // The CNAME validator is a sibling; the original validator must be unchanged. + Assert.Equal("dns-01", new LuaDnsDomainValidator().GetValidationType()); + } + + [Fact] + public void GetDomainValidatorAnnotations_SameFieldsAsTxtValidator() + { + var annotations = new LuaDnsCnameDomainValidator().GetDomainValidatorAnnotations(); + + Assert.True(annotations.ContainsKey("LuaDns_Username")); + Assert.True(annotations.ContainsKey("LuaDns_ApiKey")); + Assert.Equal("Secret", annotations["LuaDns_ApiKey"].Type); + Assert.True(annotations["LuaDns_ApiKey"].Hidden); + } + + [Theory] + [InlineData("target.sectigo.com")] + [InlineData("target.sectigo.com.")] + public async Task StageValidation_PostsCnameWithAbsoluteTarget(string target) + { + HttpRequestMessage post = null; + string body = null; + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, Zones); + } + + post = req; + body = req.Content.ReadAsStringAsync().Result; + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, "{}"); + }); + var validator = new LuaDnsCnameDomainValidator(); + validator.SetProviderForTesting(new LuaDnsProvider("user@example.com", "key", handler)); + + var result = await validator.StageValidation("_abc123.example.com", target, CancellationToken.None); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(post); + Assert.EndsWith("zones/42/records", post.RequestUri.PathAndQuery); + using var doc = JsonDocument.Parse(body); + Assert.Equal("CNAME", doc.RootElement.GetProperty("type").GetString()); + Assert.Equal("_abc123.example.com.", doc.RootElement.GetProperty("name").GetString()); + // Always exactly one trailing dot, whether or not the caller supplied one. + Assert.Equal("target.sectigo.com.", doc.RootElement.GetProperty("content").GetString()); + } + + [Fact] + public async Task StageValidation_EmptyTarget_ReturnsFailureWithoutCallingApi() + { + var handler = new FakeHttpMessageHandler(_ => FakeHttpMessageHandler.Json(HttpStatusCode.OK, Zones)); + var validator = new LuaDnsCnameDomainValidator(); + validator.SetProviderForTesting(new LuaDnsProvider("user@example.com", "key", handler)); + + var result = await validator.StageValidation("_abc123.example.com", " ", CancellationToken.None); + + Assert.False(result.Success); + Assert.Contains("CNAME", result.ErrorMessage); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task StageValidation_ApiRejection_ReturnsFailureNotException() + { + var handler = new FakeHttpMessageHandler(req => + req.Method == HttpMethod.Get + ? FakeHttpMessageHandler.Json(HttpStatusCode.OK, Zones) + : FakeHttpMessageHandler.Json(HttpStatusCode.UnprocessableEntity, "{\"error\":\"bad\"}")); + var validator = new LuaDnsCnameDomainValidator(); + validator.SetProviderForTesting(new LuaDnsProvider("user@example.com", "key", handler)); + + var result = await validator.StageValidation("_abc123.example.com", "t.sectigo.com", CancellationToken.None); + + Assert.False(result.Success); + Assert.Contains("422", result.ErrorMessage); + } + + [Fact] + public async Task CleanupValidation_DeletesMatchingCnameOnly() + { + var deleted = new List(); + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Delete) + { + deleted.Add(req.RequestUri.PathAndQuery); + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}"); + } + + if (req.RequestUri.PathAndQuery.EndsWith("zones/42/records")) + { + // A TXT at the same name must survive a CNAME cleanup. + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":1,\"zone_id\":42,\"name\":\"_abc123.example.com.\",\"type\":\"TXT\",\"content\":\"x\",\"ttl\":300}," + + "{\"id\":2,\"zone_id\":42,\"name\":\"_abc123.example.com.\",\"type\":\"CNAME\",\"content\":\"t.sectigo.com.\",\"ttl\":300}]"); + } + + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, Zones); + }); + var validator = new LuaDnsCnameDomainValidator(); + validator.SetProviderForTesting(new LuaDnsProvider("user@example.com", "key", handler)); + + var result = await validator.CleanupValidation("_abc123.example.com", CancellationToken.None); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Single(deleted); + Assert.EndsWith("zones/42/records/2", deleted[0]); + } + + [Fact] + public async Task CleanupValidation_RecordAlreadyGone_IsSuccess() + { + var handler = new FakeHttpMessageHandler(req => + req.RequestUri.PathAndQuery.EndsWith("zones/42/records") + ? FakeHttpMessageHandler.Json(HttpStatusCode.OK, "[]") + : FakeHttpMessageHandler.Json(HttpStatusCode.OK, Zones)); + var validator = new LuaDnsCnameDomainValidator(); + validator.SetProviderForTesting(new LuaDnsProvider("user@example.com", "key", handler)); + + var result = await validator.CleanupValidation("_abc123.example.com", CancellationToken.None); + + Assert.True(result.Success); + } + + [Fact] + public void Manifest_RegistersBothValidators() + { + // manifest.json is copied next to the test assembly via the main project's output. + var path = Path.Combine(AppContext.BaseDirectory, "manifest.json"); + Assert.True(File.Exists(path), $"manifest.json not found at {path}"); + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var validators = doc.RootElement + .GetProperty("extensions") + .GetProperty("Keyfactor.AnyGateway.Extensions.IDomainValidator"); + + foreach (var (key, type) in new[] + { + ("LuaDnsDomainValidator", typeof(LuaDnsDomainValidator)), + ("LuaDnsCnameDomainValidator", typeof(LuaDnsCnameDomainValidator)) + }) + { + var entry = validators.GetProperty(key); + Assert.Equal(type.FullName, entry.GetProperty("TypeFullName").GetString()); + Assert.Equal(type.Assembly.GetName().Name + ".dll", entry.GetProperty("assemblypath").GetString()); + } + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns/LuaDnsCnameDomainValidator.cs b/Keyfactor.DnsProvider.LuaDns/LuaDnsCnameDomainValidator.cs new file mode 100644 index 0000000..353f69f --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/LuaDnsCnameDomainValidator.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 +namespace Keyfactor.Extensions.DomainValidator.LuaDns +{ + /// + /// LuaDNS domain validator for CA-driven CNAME domain control validation, as used by + /// CAs that ask the customer to publish a CNAME (for example CSC Global / Sectigo). + /// Publishes a CNAME record in the LuaDNS zone that owns the record name. Shares + /// credentials and zone discovery with . + /// + public class LuaDnsCnameDomainValidator : LuaDnsDomainValidatorBase + { + protected override string ValidationTypeName => "cname"; + + protected override string RecordTypeName => "CNAME"; + + /// + /// CAs hand over the CNAME target without its trailing dot (the CSC plugin strips it). + /// LuaDNS reads a hostname without a trailing dot as relative to the zone, which would + /// turn "x.sectigo.com" into "x.sectigo.com.<zone>", so the target is made absolute. + /// + protected override string NormalizeValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("CNAME target must not be empty", nameof(value)); + } + + return value.Trim().TrimEnd('.') + "."; + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs index 7c67ea1..f8a301b 100644 --- a/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs +++ b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs @@ -1,9 +1,5 @@ // Copyright 2025 Keyfactor // Licensed under the Apache License, Version 2.0 -using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Logging; -using Microsoft.Extensions.Logging; - namespace Keyfactor.Extensions.DomainValidator.LuaDns { /// @@ -11,131 +7,10 @@ namespace Keyfactor.Extensions.DomainValidator.LuaDns /// in LuaDNS-hosted zones. Authenticates via HTTP Basic auth using the /// account's username (email) and API key. /// - public class LuaDnsDomainValidator : IDomainValidator + public class LuaDnsDomainValidator : LuaDnsDomainValidatorBase { - private static readonly ILogger _logger = LogHandler.GetClassLogger(); - - private const string ValidationTypeName = "dns-01"; - private const string RecordTypeName = "TXT"; - - private LuaDnsProvider _provider; - private Dictionary _configuration; - - public Dictionary GetDomainValidatorAnnotations() - { - return new Dictionary() - { - ["LuaDns_Username"] = new PropertyConfigInfo() - { - Comments = "LuaDNS account username (email address) (Required)", - Hidden = false, - DefaultValue = "", - Type = "String" - }, - ["LuaDns_ApiKey"] = new PropertyConfigInfo() - { - Comments = "LuaDNS API key (Required)", - Hidden = true, - DefaultValue = "", - Type = "Secret" - } - }; - } - - public string GetValidationType() => ValidationTypeName; - - public void Initialize(IDomainValidatorConfigProvider configProvider) - { - _configuration = configProvider.DomainValidationConfiguration; - - var username = GetConfigValue("LuaDns_Username"); - var apiKey = GetConfigValue("LuaDns_ApiKey"); - - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("LuaDns_Username is required"); - } - - if (string.IsNullOrWhiteSpace(apiKey)) - { - throw new ArgumentException("LuaDns_ApiKey is required"); - } - - _provider = new LuaDnsProvider(username, apiKey); - } - - public async Task StageValidation(string key, string value, CancellationToken cancellationToken) - { - try - { - var success = await _provider.CreateRecordAsync(key, value, RecordTypeName); - - return new DomainValidationResult - { - Success = success, - ErrorMessage = success ? null : $"Failed to create DNS {RecordTypeName} record for {key}" - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "LuaDNS StageValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); - return new DomainValidationResult - { - Success = false, - ErrorMessage = $"Failed to create {RecordTypeName} record for {key}: {ex.Message}" - }; - } - } - - public async Task CleanupValidation(string key, CancellationToken cancellationToken) - { - try - { - var success = await _provider.DeleteRecordAsync(key, RecordTypeName); - - return new DomainValidationResult - { - Success = success, - ErrorMessage = success ? null : $"Failed to delete DNS {RecordTypeName} record for {key}" - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "LuaDNS CleanupValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); - return new DomainValidationResult - { - Success = false, - ErrorMessage = $"Failed to delete {RecordTypeName} record for {key}: {ex.Message}" - }; - } - } - - public async Task ValidateConfiguration(Dictionary configuration) - { - _configuration = configuration; - - var username = GetConfigValue("LuaDns_Username"); - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("LuaDns_Username is required"); - } - - var apiKey = GetConfigValue("LuaDns_ApiKey"); - if (string.IsNullOrWhiteSpace(apiKey)) - { - throw new ArgumentException("LuaDns_ApiKey is required"); - } - - await Task.CompletedTask; - } + protected override string ValidationTypeName => "dns-01"; - private string GetConfigValue(string key) - { - if (_configuration != null && _configuration.TryGetValue(key, out var value)) - { - return value?.ToString() ?? string.Empty; - } - return string.Empty; - } + protected override string RecordTypeName => "TXT"; } } diff --git a/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidatorBase.cs b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidatorBase.cs new file mode 100644 index 0000000..abf9b90 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidatorBase.cs @@ -0,0 +1,154 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns +{ + /// + /// Shared behavior for the LuaDNS domain validators: configuration, credentials and + /// the publish/cleanup flow against LuaDNS. Concrete validators differ only in the + /// validation type they advertise to the gateway and the DNS record type they write. + /// + public abstract class LuaDnsDomainValidatorBase : IDomainValidator + { + private static readonly ILogger _logger = LogHandler.GetClassLogger(); + + private LuaDnsProvider _provider; + private Dictionary _configuration; + + /// Validation type the gateway matches against (e.g. "dns-01", "cname"). + protected abstract string ValidationTypeName { get; } + + /// DNS record type this validator creates and deletes (e.g. "TXT", "CNAME"). + protected abstract string RecordTypeName { get; } + + /// + /// Adapts the value handed over by the caller to what LuaDNS expects for + /// . The default is a pass-through. + /// + protected virtual string NormalizeValue(string value) => value; + + public Dictionary GetDomainValidatorAnnotations() + { + return new Dictionary() + { + ["LuaDns_Username"] = new PropertyConfigInfo() + { + Comments = "LuaDNS account username (email address) (Required)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["LuaDns_ApiKey"] = new PropertyConfigInfo() + { + Comments = "LuaDNS API key (Required)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + }; + } + + public string GetValidationType() => ValidationTypeName; + + public void Initialize(IDomainValidatorConfigProvider configProvider) + { + _configuration = configProvider.DomainValidationConfiguration; + + var username = GetConfigValue("LuaDns_Username"); + var apiKey = GetConfigValue("LuaDns_ApiKey"); + + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("LuaDns_Username is required"); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("LuaDns_ApiKey is required"); + } + + _provider = new LuaDnsProvider(username, apiKey); + } + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + try + { + var success = await _provider.CreateRecordAsync(key, NormalizeValue(value), RecordTypeName); + + return new DomainValidationResult + { + Success = success, + ErrorMessage = success ? null : $"Failed to create DNS {RecordTypeName} record for {key}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "LuaDNS StageValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Failed to create {RecordTypeName} record for {key}: {ex.Message}" + }; + } + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + try + { + var success = await _provider.DeleteRecordAsync(key, RecordTypeName); + + return new DomainValidationResult + { + Success = success, + ErrorMessage = success ? null : $"Failed to delete DNS {RecordTypeName} record for {key}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "LuaDNS CleanupValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Failed to delete {RecordTypeName} record for {key}: {ex.Message}" + }; + } + } + + public async Task ValidateConfiguration(Dictionary configuration) + { + _configuration = configuration; + + var username = GetConfigValue("LuaDns_Username"); + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("LuaDns_Username is required"); + } + + var apiKey = GetConfigValue("LuaDns_ApiKey"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("LuaDns_ApiKey is required"); + } + + await Task.CompletedTask; + } + + // Test seam: lets unit tests drive Stage/Cleanup against a fake HTTP handler + // without going through Initialize (which would construct a real HttpClient). + internal void SetProviderForTesting(LuaDnsProvider provider) => _provider = provider; + + private string GetConfigValue(string key) + { + if (_configuration != null && _configuration.TryGetValue(key, out var value)) + { + return value?.ToString() ?? string.Empty; + } + return string.Empty; + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns/manifest.json b/Keyfactor.DnsProvider.LuaDns/manifest.json index ce629f7..e9b91c2 100644 --- a/Keyfactor.DnsProvider.LuaDns/manifest.json +++ b/Keyfactor.DnsProvider.LuaDns/manifest.json @@ -4,6 +4,10 @@ "LuaDnsDomainValidator": { "assemblypath": "LuaDnsDomainValidator.dll", "TypeFullName": "Keyfactor.Extensions.DomainValidator.LuaDns.LuaDnsDomainValidator" + }, + "LuaDnsCnameDomainValidator": { + "assemblypath": "LuaDnsDomainValidator.dll", + "TypeFullName": "Keyfactor.Extensions.DomainValidator.LuaDns.LuaDnsCnameDomainValidator" } } } diff --git a/README.md b/README.md index 8b2efcc..bb842f3 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The LuaDNS Provider plugin enables automated DNS-based domain validation for Key - Automated DNS TXT record creation and deletion in LuaDNS - HTTP Basic authentication using the account's username (email) and API key - Automatic zone discovery across all zones on the account, matched by longest domain suffix +- Two validators sharing the same credentials: `dns-01` (publishes TXT records for ACME DNS-01) and `cname` (publishes CNAME records for CAs that require CNAME domain control validation, such as CSC Global) ## Requirements @@ -158,6 +159,13 @@ Symptom: `401 Unauthorized` listing zones - Verify the API key has not been revoked in the LuaDNS dashboard - Confirm `LuaDns_Username` is the account's login email, not a display name +**CA reports "No DNS provider matched domain ... for validation type 'cname'"** + +Symptom: the CA plugin (for example CSC Global) logs `unresolved` and asks for manual publishing + +- Create a Domain Validation Configuration of type `LuaDnsCnameDomainValidator`; a `LuaDnsDomainValidator` (`dns-01`) configuration does not match `cname` requests +- Confirm the record's domain is covered by that configuration's domain list + **Zone Not Found** Symptom: `No LuaDNS zone found for example.com` diff --git a/docsource/configuration.md b/docsource/configuration.md index b13eb56..496036b 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -19,6 +19,19 @@ Provide the username and key as `LuaDns_Username` and `LuaDns_ApiKey` in the plu } ``` +### Validator Types + +The plugin registers two validators in `manifest.json`. Both take the same `LuaDns_Username` and `LuaDns_ApiKey` fields; choose the one whose validation type the requesting CA or protocol asks for: + +| Validator | Validation type | Record written | Used for | +|-----------|-----------------|----------------|----------| +| `LuaDnsDomainValidator` | `dns-01` | `TXT` | ACME DNS-01 challenges | +| `LuaDnsCnameDomainValidator` | `cname` | `CNAME` | CAs that ask for a CNAME to be published for domain control validation (for example CSC Global / Sectigo) | + +A CA plugin that asks the gateway for a `cname` validator only matches a Domain Validation Configuration of type `LuaDnsCnameDomainValidator`; a `dns-01` configuration will not satisfy it. Create one configuration per type if you need both, listing the domains each should serve. + +For `cname`, the CNAME target is published as an absolute name (a trailing dot is added if the CA omits it), so LuaDNS does not read it as relative to the zone. + ### Zone Discovery The plugin discovers the appropriate LuaDNS zone for a domain by querying the LuaDNS API for all zones on the account, then matching the record's domain against zone names from most specific (longest) to least specific. @@ -41,6 +54,13 @@ Symptom: `401 Unauthorized` listing zones - Verify the API key has not been revoked in the LuaDNS dashboard - Confirm `LuaDns_Username` is the account's login email, not a display name +**CA reports "No DNS provider matched domain ... for validation type 'cname'"** + +Symptom: the CA plugin (for example CSC Global) logs `unresolved` and asks for manual publishing + +- Create a Domain Validation Configuration of type `LuaDnsCnameDomainValidator`; a `LuaDnsDomainValidator` (`dns-01`) configuration does not match `cname` requests +- Confirm the record's domain is covered by that configuration's domain list + **Zone Not Found** Symptom: `No LuaDNS zone found for example.com` diff --git a/docsource/content.md b/docsource/content.md index 7fe436d..acbf9b8 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -6,6 +6,7 @@ The LuaDNS Provider plugin enables automated DNS-based domain validation for Key - HTTP Basic authentication using the account's username (email) and API key - Automatic zone discovery across all zones on the account, matched by longest domain suffix +- Two validators sharing the same credentials: `dns-01` (publishes TXT records for ACME DNS-01) and `cname` (publishes CNAME records for CAs that require CNAME domain control validation, such as CSC Global) ## Requirements diff --git a/integration-manifest.json b/integration-manifest.json index 2af2483..67281ea 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,11 +1,11 @@ { "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", - "integration_type": "dns-plugin", "name": "LuaDNS DNS Plugin", - "status": "production", + "integration_type": "dns-plugin", "support_level": "kf-supported", + "status": "production", "update_catalog": true, - "link_github": false, + "link_github": true, "description": "DNS-01 challenge validation provider using LuaDNS. Implements the IDomainValidator interface to create, manage, and clean up DNS TXT records in LuaDNS-hosted zones for ACME domain validation. Authenticates via HTTP Basic auth using the account's username (email) and API key.", "release_dir": "Keyfactor.DnsProvider.LuaDns/bin/Release", "release_project": "Keyfactor.DnsProvider.LuaDns/Keyfactor.DnsProvider.LuaDns.csproj",