From 321e80b5243ba59eb5bdc3c5ec7c6fb678be6cbf Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 13 Aug 2026 13:32:23 -0400 Subject: [PATCH 1/2] Scaffold LuaDNS DNS-01 domain validator plugin Lays out the project the same way as cloudflare-cloudflaredns-dnsplugin and ports the zone lookup and TXT record create/delete logic from win-acme's plugin.validation.dns.luadns, adapted to be stateless (record lookup for delete instead of an in-memory record map) and to surface API failures via exceptions with status/body detail. Includes unit tests covering zone-matching and record CRUD against a faked HTTP handler, plus config validation on the domain validator. --- .../workflows/keyfactor-starter-workflow.yml | 27 +++ .gitignore | 4 + CHANGELOG.md | 2 + .../FakeHttpMessageHandler.cs | 35 +++ .../Keyfactor.DnsProvider.LuaDns.Tests.csproj | 19 ++ .../LuaDnsDomainValidatorTests.cs | 60 +++++ .../LuaDnsProviderTests.cs | 205 ++++++++++++++++ Keyfactor.DnsProvider.LuaDns.slnx | 4 + Keyfactor.DnsProvider.LuaDns/AssemblyInfo.cs | 3 + .../Keyfactor.DnsProvider.LuaDns.csproj | 19 ++ .../LuaDnsDomainValidator.cs | 141 +++++++++++ .../LuaDnsProvider.cs | 225 ++++++++++++++++++ Keyfactor.DnsProvider.LuaDns/manifest.json | 10 + README.md | 5 + docsource/configuration.md | 49 ++++ docsource/content.md | 13 + integration-manifest.json | 46 ++++ 17 files changed, 867 insertions(+) create mode 100644 .github/workflows/keyfactor-starter-workflow.yml create mode 100644 CHANGELOG.md create mode 100644 Keyfactor.DnsProvider.LuaDns.Tests/FakeHttpMessageHandler.cs create mode 100644 Keyfactor.DnsProvider.LuaDns.Tests/Keyfactor.DnsProvider.LuaDns.Tests.csproj create mode 100644 Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsDomainValidatorTests.cs create mode 100644 Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsProviderTests.cs create mode 100644 Keyfactor.DnsProvider.LuaDns.slnx create mode 100644 Keyfactor.DnsProvider.LuaDns/AssemblyInfo.cs create mode 100644 Keyfactor.DnsProvider.LuaDns/Keyfactor.DnsProvider.LuaDns.csproj create mode 100644 Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs create mode 100644 Keyfactor.DnsProvider.LuaDns/LuaDnsProvider.cs create mode 100644 Keyfactor.DnsProvider.LuaDns/manifest.json create mode 100644 README.md create mode 100644 docsource/configuration.md create mode 100644 docsource/content.md create mode 100644 integration-manifest.json diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml new file mode 100644 index 0000000..0f3d3ae --- /dev/null +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -0,0 +1,27 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index d5a18de..3a9bb62 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,7 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Claude Code / agent state, and local vendor secrets +.claude/ +.secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..78d2335 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +v1.0.0 +- Inital Version diff --git a/Keyfactor.DnsProvider.LuaDns.Tests/FakeHttpMessageHandler.cs b/Keyfactor.DnsProvider.LuaDns.Tests/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..bf3b7b8 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.Tests/FakeHttpMessageHandler.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns.Tests +{ + /// + /// Routes requests to a caller-supplied responder so LuaDnsProvider can be + /// exercised end-to-end without touching the real LuaDNS API. + /// + internal class FakeHttpMessageHandler : HttpMessageHandler + { + public List Requests { get; } = new(); + + private readonly Func _responder; + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responder(request)); + } + + public static HttpResponseMessage Json(HttpStatusCode status, string body) + { + return new HttpResponseMessage(status) + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns.Tests/Keyfactor.DnsProvider.LuaDns.Tests.csproj b/Keyfactor.DnsProvider.LuaDns.Tests/Keyfactor.DnsProvider.LuaDns.Tests.csproj new file mode 100644 index 0000000..4b93333 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.Tests/Keyfactor.DnsProvider.LuaDns.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + disable + false + Keyfactor.Extensions.DomainValidator.LuaDns.Tests + + + + + + + + + + + + diff --git a/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsDomainValidatorTests.cs b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsDomainValidatorTests.cs new file mode 100644 index 0000000..b57b865 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsDomainValidatorTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns.Tests +{ + public class LuaDnsDomainValidatorTests + { + [Fact] + public void GetValidationType_ReturnsDns01() + { + var validator = new LuaDnsDomainValidator(); + + Assert.Equal("dns-01", validator.GetValidationType()); + } + + [Fact] + public void GetDomainValidatorAnnotations_DeclaresUsernameAndApiKey() + { + var validator = new LuaDnsDomainValidator(); + + var annotations = validator.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); + Assert.False(annotations["LuaDns_Username"].Hidden); + } + + [Fact] + public async Task ValidateConfiguration_ThrowsWhenUsernameMissing() + { + var validator = new LuaDnsDomainValidator(); + var config = new Dictionary { ["LuaDns_ApiKey"] = "key" }; + + await Assert.ThrowsAsync(() => validator.ValidateConfiguration(config)); + } + + [Fact] + public async Task ValidateConfiguration_ThrowsWhenApiKeyMissing() + { + var validator = new LuaDnsDomainValidator(); + var config = new Dictionary { ["LuaDns_Username"] = "user@example.com" }; + + await Assert.ThrowsAsync(() => validator.ValidateConfiguration(config)); + } + + [Fact] + public async Task ValidateConfiguration_SucceedsWhenBothFieldsPresent() + { + var validator = new LuaDnsDomainValidator(); + var config = new Dictionary + { + ["LuaDns_Username"] = "user@example.com", + ["LuaDns_ApiKey"] = "key" + }; + + await validator.ValidateConfiguration(config); + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsProviderTests.cs b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsProviderTests.cs new file mode 100644 index 0000000..ebd5d11 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsProviderTests.cs @@ -0,0 +1,205 @@ +using System.Net; +using Xunit; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns.Tests +{ + public class LuaDnsProviderTests + { + [Fact] + public void FindBestMatch_PicksLongestMatchingSuffix() + { + var zones = new Dictionary + { + ["com"] = 1, + ["example.com"] = 2, + ["other.com"] = 3 + }; + + var match = LuaDnsProvider.FindBestMatch(zones, "_acme-challenge.www.example.com"); + + Assert.NotNull(match); + Assert.Equal("example.com", match.Value.Key); + Assert.Equal(2, match.Value.Value); + } + + [Fact] + public void FindBestMatch_MatchesExactZoneName() + { + var zones = new Dictionary { ["example.com"] = 2 }; + + var match = LuaDnsProvider.FindBestMatch(zones, "example.com"); + + Assert.NotNull(match); + Assert.Equal("example.com", match.Value.Key); + } + + [Fact] + public void FindBestMatch_ReturnsNullWhenNoZoneMatches() + { + var zones = new Dictionary { ["example.com"] = 2 }; + + var match = LuaDnsProvider.FindBestMatch(zones, "unrelated-domain.net"); + + Assert.Null(match); + } + + [Fact] + public void FindBestMatch_DoesNotMatchUnrelatedSuffixSubstring() + { + // "notexample.com" must not match zone "example.com" just because it ends with the same characters. + var zones = new Dictionary { ["example.com"] = 2 }; + + var match = LuaDnsProvider.FindBestMatch(zones, "notexample.com"); + + Assert.Null(match); + } + + [Fact] + public async Task CreateRecordAsync_PostsTxtRecordToResolvedZone() + { + HttpRequestMessage postRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.EndsWith("zones")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":42,\"name\":\"example.com.\"}]"); + } + + if (req.Method == HttpMethod.Post) + { + postRequest = req; + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"id\":1,\"zone_id\":42,\"name\":\"_acme-challenge.example.com.\",\"type\":\"TXT\",\"content\":\"abc123\",\"ttl\":300}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new LuaDnsProvider("user@example.com", "apikey", handler); + + var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"); + + Assert.True(result); + Assert.NotNull(postRequest); + Assert.EndsWith("zones/42/records", postRequest.RequestUri.PathAndQuery); + + var body = await postRequest.Content.ReadAsStringAsync(); + Assert.Contains("\"name\":\"_acme-challenge.example.com.\"", body); + Assert.Contains("\"type\":\"TXT\"", body); + Assert.Contains("\"content\":\"abc123\"", body); + } + + [Fact] + public async Task CreateRecordAsync_ThrowsWithApiDetailsWhenZoneApiRejects() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.EndsWith("zones")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":42,\"name\":\"example.com.\"}]"); + } + + return FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, "{\"error\":\"invalid content\"}"); + }); + + var provider = new LuaDnsProvider("user@example.com", "apikey", handler); + + var ex = await Assert.ThrowsAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT")); + + Assert.Contains("400", ex.Message); + Assert.Contains("example.com", ex.Message); + } + + [Fact] + public async Task CreateRecordAsync_ThrowsWhenNoZoneMatches() + { + var handler = new FakeHttpMessageHandler(req => + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "[{\"id\":42,\"name\":\"other.com.\"}]")); + + var provider = new LuaDnsProvider("user@example.com", "apikey", handler); + + var ex = await Assert.ThrowsAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT")); + + Assert.Contains("No LuaDNS zone found", ex.Message); + } + + [Fact] + public async Task DeleteRecordAsync_DeletesMatchingRecord() + { + HttpRequestMessage deleteRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.EndsWith("zones")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":42,\"name\":\"example.com.\"}]"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":7,\"zone_id\":42,\"name\":\"_acme-challenge.example.com.\",\"type\":\"TXT\",\"content\":\"abc123\",\"ttl\":300}]"); + } + + if (req.Method == HttpMethod.Delete) + { + deleteRequest = req; + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new LuaDnsProvider("user@example.com", "apikey", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT"); + + Assert.True(result); + Assert.NotNull(deleteRequest); + Assert.EndsWith("zones/42/records/7", deleteRequest.RequestUri.PathAndQuery); + } + + [Fact] + public async Task DeleteRecordAsync_IsIdempotentWhenRecordAlreadyGone() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.EndsWith("zones")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "[{\"id\":42,\"name\":\"example.com.\"}]"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "[]"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new LuaDnsProvider("user@example.com", "apikey", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT"); + + Assert.True(result); + } + + [Theory] + [InlineData(null, "apikey")] + [InlineData("", "apikey")] + [InlineData("user@example.com", null)] + [InlineData("user@example.com", "")] + public void Constructor_ThrowsOnMissingCredentials(string username, string apiKey) + { + Assert.Throws(() => new LuaDnsProvider(username, apiKey, new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("Should not make HTTP calls")))); + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns.slnx b/Keyfactor.DnsProvider.LuaDns.slnx new file mode 100644 index 0000000..4ded465 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Keyfactor.DnsProvider.LuaDns/AssemblyInfo.cs b/Keyfactor.DnsProvider.LuaDns/AssemblyInfo.cs new file mode 100644 index 0000000..411b861 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Keyfactor.DnsProvider.LuaDns.Tests")] diff --git a/Keyfactor.DnsProvider.LuaDns/Keyfactor.DnsProvider.LuaDns.csproj b/Keyfactor.DnsProvider.LuaDns/Keyfactor.DnsProvider.LuaDns.csproj new file mode 100644 index 0000000..d3e5c2c --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/Keyfactor.DnsProvider.LuaDns.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + disable + true + Keyfactor.Extensions.DomainValidator.LuaDns + LuaDnsDomainValidator + + + + + + + + Always + + + diff --git a/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs new file mode 100644 index 0000000..7c67ea1 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs @@ -0,0 +1,141 @@ +// 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 +{ + /// + /// LuaDNS domain validator for ACME DNS-01 challenges. Publishes TXT records + /// in LuaDNS-hosted zones. Authenticates via HTTP Basic auth using the + /// account's username (email) and API key. + /// + public class LuaDnsDomainValidator : IDomainValidator + { + 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; + } + + 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/LuaDnsProvider.cs b/Keyfactor.DnsProvider.LuaDns/LuaDnsProvider.cs new file mode 100644 index 0000000..da3308d --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/LuaDnsProvider.cs @@ -0,0 +1,225 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.DomainValidator.LuaDns +{ + internal class LuaDnsProvider + { + private static readonly ILogger _logger = LogHandler.GetClassLogger(); + + private readonly HttpClient _httpClient; + + private class ZoneData + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + private class RecordData + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("zone_id")] + public int ZoneId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } + + [JsonPropertyName("ttl")] + public int TTL { get; set; } + } + + public LuaDnsProvider(string username, string apiKey) + : this(username, apiKey, new HttpClientHandler()) + { + } + + // Internal constructor to allow unit tests to inject a fake HttpMessageHandler. + internal LuaDnsProvider(string username, string apiKey, HttpMessageHandler handler) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("Username must not be empty", nameof(username)); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("apiKey must not be empty", nameof(apiKey)); + } + + _httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.luadns.com/v1/") + }; + + var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{apiKey}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async Task CreateRecordAsync(string recordName, string value, string recordType) + { + _logger.LogDebug("Creating {RecordType} record for {RecordName}", recordType, recordName); + + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + + var fqdn = recordName.TrimEnd('.') + "."; + var payload = new { name = fqdn, type = recordType, content = value, ttl = 300 }; + var json = JsonSerializer.Serialize(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"zones/{zoneId}/records", content); + var result = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "LuaDNS API rejected creation of {RecordType} record '{Fqdn}' in zone '{ZoneName}' ({ZoneId}). Status: {StatusCode}. Response: {Response}", + recordType, fqdn, zoneName, zoneId, (int)response.StatusCode, result); + + throw new InvalidOperationException( + $"LuaDNS API returned {(int)response.StatusCode} ({response.StatusCode}) creating {recordType} record '{fqdn}' in zone '{zoneName}': {result}"); + } + + _logger.LogInformation( + "Created {RecordType} record '{Fqdn}' in LuaDNS zone '{ZoneName}'", + recordType, fqdn, zoneName); + return true; + } + + public async Task DeleteRecordAsync(string recordName, string recordType) + { + _logger.LogDebug("Deleting {RecordType} record for {RecordName}", recordType, recordName); + + var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName); + + var fqdn = recordName.TrimEnd('.') + "."; + + var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/records"); + var recordsBody = await recordsResp.Content.ReadAsStringAsync(); + if (!recordsResp.IsSuccessStatusCode) + { + _logger.LogError( + "LuaDNS API failed to list records for zone '{ZoneName}'. Status: {StatusCode}. Response: {Response}", + zoneName, (int)recordsResp.StatusCode, recordsBody); + + throw new InvalidOperationException( + $"LuaDNS API returned {(int)recordsResp.StatusCode} ({recordsResp.StatusCode}) listing records in zone '{zoneName}': {recordsBody}"); + } + + var records = JsonSerializer.Deserialize(recordsBody) ?? Array.Empty(); + var match = records.FirstOrDefault(r => + string.Equals(r.Type, recordType, StringComparison.OrdinalIgnoreCase) && + string.Equals((r.Name ?? string.Empty).TrimEnd('.'), fqdn.TrimEnd('.'), StringComparison.OrdinalIgnoreCase)); + + if (match == null) + { + // Nothing to clean up — treat as success so cleanup is idempotent. + _logger.LogInformation( + "No {RecordType} record '{Fqdn}' found in zone '{ZoneName}' to delete; treating cleanup as complete", + recordType, fqdn, zoneName); + return true; + } + + var deleteResp = await _httpClient.DeleteAsync($"zones/{zoneId}/records/{match.Id}"); + var deleteBody = await deleteResp.Content.ReadAsStringAsync(); + + if (!deleteResp.IsSuccessStatusCode) + { + _logger.LogError( + "LuaDNS API rejected deletion of {RecordType} record '{Fqdn}' ({RecordId}) in zone '{ZoneName}'. Status: {StatusCode}. Response: {Response}", + recordType, fqdn, match.Id, zoneName, (int)deleteResp.StatusCode, deleteBody); + + throw new InvalidOperationException( + $"LuaDNS API returned {(int)deleteResp.StatusCode} ({deleteResp.StatusCode}) deleting {recordType} record '{fqdn}' in zone '{zoneName}': {deleteBody}"); + } + + _logger.LogInformation( + "Deleted {RecordType} record '{Fqdn}' in LuaDNS zone '{ZoneName}'", + recordType, fqdn, zoneName); + return true; + } + + /// + /// Fetches all zones for the account and resolves the zone that owns the given record + /// by longest matching name suffix, e.g. for "_acme-challenge.www.example.com" it tries + /// "www.example.com", then "example.com", etc. against the account's zone names. + /// + private async Task<(string zoneName, string zoneId)> FindZoneForRecordAsync(string recordName) + { + if (string.IsNullOrWhiteSpace(recordName)) + { + throw new ArgumentException("Record name must not be empty", nameof(recordName)); + } + + var response = await _httpClient.GetAsync("zones"); + var body = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "LuaDNS zone list request failed. Status: {StatusCode}. Response: {Response}. " + + "This usually means the account username or API key is invalid.", + (int)response.StatusCode, body); + + throw new InvalidOperationException( + $"LuaDNS API returned {(int)response.StatusCode} ({response.StatusCode}) while listing zones: {body}. " + + "Verify the configured username and API key."); + } + + var zones = JsonSerializer.Deserialize(body) ?? Array.Empty(); + if (zones.Length == 0 || zones.Any(z => z.Name == null)) + { + throw new InvalidOperationException("LuaDNS returned an empty or invalid zones list. Aborting."); + } + + var zoneMap = zones.ToDictionary(z => z.Name.TrimEnd('.'), z => z.Id, StringComparer.OrdinalIgnoreCase); + var match = FindBestMatch(zoneMap, recordName.TrimEnd('.')); + + if (match == null) + { + throw new InvalidOperationException( + $"No LuaDNS zone found for record '{recordName}'. Ensure the zone exists in this LuaDNS account."); + } + + return (match.Value.Key, match.Value.Value.ToString()); + } + + /// + /// Finds the zone whose name is the longest suffix match of the target domain, + /// e.g. for domain "_acme-challenge.www.example.com" and zones {"example.com", "com"}, + /// "example.com" wins because it's the more specific (longer) match. + /// + internal static KeyValuePair? FindBestMatch(Dictionary zones, string domain) + { + KeyValuePair? best = null; + foreach (var zone in zones) + { + var isMatch = domain.Equals(zone.Key, StringComparison.OrdinalIgnoreCase) || + domain.EndsWith("." + zone.Key, StringComparison.OrdinalIgnoreCase); + + if (isMatch && (best == null || zone.Key.Length > best.Value.Key.Length)) + { + best = zone; + } + } + return best; + } + } +} diff --git a/Keyfactor.DnsProvider.LuaDns/manifest.json b/Keyfactor.DnsProvider.LuaDns/manifest.json new file mode 100644 index 0000000..ce629f7 --- /dev/null +++ b/Keyfactor.DnsProvider.LuaDns/manifest.json @@ -0,0 +1,10 @@ +{ + "extensions": { + "Keyfactor.AnyGateway.Extensions.IDomainValidator": { + "LuaDnsDomainValidator": { + "assemblypath": "LuaDnsDomainValidator.dll", + "TypeFullName": "Keyfactor.Extensions.DomainValidator.LuaDns.LuaDnsDomainValidator" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceaa730 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# LuaDNS DNS Provider + +DNS-01 challenge validation provider using LuaDNS. This README is regenerated by the Keyfactor doctool from `integration-manifest.json` and `docsource/` on every CI run. + +See [docsource/content.md](docsource/content.md) and [docsource/configuration.md](docsource/configuration.md) for the source content. diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..b13eb56 --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,49 @@ +### Provider Setup + +Create a LuaDNS API key for the account that owns the zones you want the plugin to manage: + +1. Log in to the LuaDNS dashboard +2. Navigate to **Account > API Keys** +3. Create a new API key and copy the value — it authenticates alongside your account username (email) + +Provide the username and key as `LuaDns_Username` and `LuaDns_ApiKey` in the plugin configuration below. + +### Example Configurations + +**Standard configuration:** + +```json +{ + "LuaDns_Username": "you@example.com", + "LuaDns_ApiKey": "your-luadns-api-key" +} +``` + +### 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. + +### Testing Connectivity + +Test LuaDNS connectivity using `curl` against the API: + +```bash +# List zones accessible to the account (validates username/API key) +curl -s -u "you@example.com:$LUADNS_API_KEY" https://api.luadns.com/v1/zones +``` + +### Troubleshooting + +**Authentication Failures** + +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 + +**Zone Not Found** + +Symptom: `No LuaDNS zone found for example.com` + +- Verify the zone exists and is active in the LuaDNS account +- Confirm the account associated with the API key owns that zone diff --git a/docsource/content.md b/docsource/content.md new file mode 100644 index 0000000..7fe436d --- /dev/null +++ b/docsource/content.md @@ -0,0 +1,13 @@ +## Overview + +The LuaDNS Provider plugin enables automated DNS-based domain validation for Keyfactor certificate lifecycle management through LuaDNS. This plugin integrates with the LuaDNS API to automatically create, verify, and delete DNS TXT records required for domain validation during certificate issuance and renewal. + +## Features + +- 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 + +## Requirements + +- A LuaDNS account with one or more DNS zones managed by LuaDNS +- A LuaDNS API key (create under **Account > API Keys** in the LuaDNS dashboard) diff --git a/integration-manifest.json b/integration-manifest.json new file mode 100644 index 0000000..2af2483 --- /dev/null +++ b/integration-manifest.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "dns-plugin", + "name": "LuaDNS DNS Plugin", + "status": "production", + "support_level": "kf-supported", + "update_catalog": true, + "link_github": false, + "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", + "about": { + "dns_provider": { + "providerName": "luadns", + "displayName": "LuaDNS", + "assemblyName": "LuaDnsDomainValidator", + "fullyQualifiedClassName": "Keyfactor.Extensions.DomainValidator.LuaDns.LuaDnsDomainValidator", + "validationType": "dns-01", + "providerEndpoint": "api.luadns.com", + "providerDocsUrl": "https://www.luadns.com/api.html", + "serviceStatusUrl": "https://www.luadns.com/", + "dns_provider_config": [ + { + "Name": "LuaDns_Username", + "DisplayName": "LuaDNS Username", + "DataType": 1, + "InstanceLevel": false, + "Hidden": false, + "DefaultValue": "", + "Required": true, + "Description": "LuaDNS account username (email address) used for HTTP Basic authentication." + }, + { + "Name": "LuaDns_ApiKey", + "DisplayName": "LuaDNS API Key", + "DataType": 2, + "InstanceLevel": false, + "Hidden": true, + "DefaultValue": "", + "Required": true, + "Description": "LuaDNS API key used for HTTP Basic authentication. Found under Account > API Keys in the LuaDNS dashboard." + } + ] + } + } +} From 48c20cfd8a9b558ca2870059b243adde6ed2fe13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Aug 2026 17:33:14 +0000 Subject: [PATCH 2/2] docs: auto-generate README and documentation [skip ci] --- README.md | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ceaa730..8b2efcc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,204 @@ -# LuaDNS DNS Provider +

+ LuaDNS DNS Provider +

-DNS-01 challenge validation provider using LuaDNS. This README is regenerated by the Keyfactor doctool from `integration-manifest.json` and `docsource/` on every CI run. +

+ +Integration Status: production +Build +Release +Issues +GitHub Downloads (all assets, all releases) +

-See [docsource/content.md](docsource/content.md) and [docsource/configuration.md](docsource/configuration.md) for the source content. +

+ + + Support + + · + + Requirements + + · + + Installation + + · + + License + + · + + Related Integrations + +

+ +## Overview + +The LuaDNS Provider plugin enables automated DNS-based domain validation for Keyfactor certificate lifecycle management through LuaDNS. This plugin integrates with the LuaDNS API to automatically create, verify, and delete DNS TXT records required for domain validation during certificate issuance and renewal. + +## Features + +- 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 + +## Requirements + +### Keyfactor Platform +- Keyfactor AnyCA Gateway REST **26.2 or later** (DNS validation support was added in AnyCA Gateway 26.2) +- A gateway product that supports DNS-01 domain validation (ACME REST Gateway, DigiCert, Sectigo, etc.) + +### LuaDNS Requirements + +- A LuaDNS account with one or more DNS zones managed by LuaDNS +- A LuaDNS API key (create under **Account > API Keys** in the LuaDNS dashboard) + +### Runtime Requirements +- .NET 10.0 runtime (provided by the gateway server) +- Network connectivity to api.luadns.com (HTTPS/443) + +## Installation + +This plugin is installed alongside any Keyfactor gateway server that supports DNS-01 domain validation (ACME REST Gateway, DigiCert, Sectigo, etc.). The same DLL works with every supported gateway. + +> See the official Keyfactor AnyCA Gateway REST installation documentation for the authoritative install instructions: ****. The steps below are a general guide; defer to the official docs if they diverge. + +### 1. Download the Plugin + +Download the latest release from the [Releases](https://github.com/Keyfactor/luadns-dnsplugin/releases) page. + +### 2. Copy the plugin DLLs to the gateway's Extensions folder + +On the server hosting your gateway, unzip the release and copy the contents of the `net10.0` directory into the gateway's `Extensions` folder. + +**Windows** (example path — substitute the gateway product folder for your install): + +```text +C:\Program Files\Keyfactor\\AnyGatewayREST\net10.0\Extensions\ +``` + +**Linux**: + +```text +/opt/keyfactor//AnyGatewayREST/net10.0/Extensions/ +``` + +Replace `` (or `` on Linux) with the gateway you are installing into (e.g. `AcmeGwDns`, `DigiCert`, `Sectigo`). + +### 3. Restart the gateway service + +Restart the AnyGatewayREST Windows service for the gateway you installed the plugin into so the Extensions folder is rescanned. + +## Configuration + +After installing the plugin DLL into the gateway's Extensions folder, configure a new DNS Provider entry in the AnyCA Gateway REST UI and select **LuaDNS** as the provider type. See the official Keyfactor AnyCA Gateway REST documentation for the canonical UI walkthrough: ****. + +### LuaDNS Setup + +Create a LuaDNS API key for the account that owns the zones you want the plugin to manage: + +1. Log in to the LuaDNS dashboard +2. Navigate to **Account > API Keys** +3. Create a new API key and copy the value — it authenticates alongside your account username (email) + +Provide the username and key as `LuaDns_Username` and `LuaDns_ApiKey` in the plugin configuration below. + +### Configuration Parameters + +| Parameter | Description | Required | Example | +|-----------|-------------|----------|---------| +| `LuaDns_Username` | LuaDNS account username (email address) used for HTTP Basic authentication. | Yes | ` ` | +| `LuaDns_ApiKey` | LuaDNS API key used for HTTP Basic authentication. Found under Account > API Keys in the LuaDNS dashboard. | Yes | ` ` | + +### Example Configuration + +**Standard configuration:** + +```json +{ + "LuaDns_Username": "you@example.com", + "LuaDns_ApiKey": "your-luadns-api-key" +} +``` + +## Usage + +### Automatic Domain Validation + +Once configured, the plugin automatically handles DNS validation during certificate enrollment and renewal: + +1. **Record Creation**: Plugin creates a DNS TXT record with the validation challenge +2. **Propagation Wait**: Plugin waits for DNS propagation +3. **Verification**: Plugin verifies the record exists on LuaDNS nameservers +4. **Cleanup**: Plugin deletes the validation record after successful validation + +### 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. + +### Testing Connectivity + +Test LuaDNS connectivity using `curl` against the API: + +```bash +# List zones accessible to the account (validates username/API key) +curl -s -u "you@example.com:$LUADNS_API_KEY" https://api.luadns.com/v1/zones +``` + +## Troubleshooting + +### Common Issues + +**Authentication Failures** + +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 + +**Zone Not Found** + +Symptom: `No LuaDNS zone found for example.com` + +- Verify the zone exists and is active in the LuaDNS account +- Confirm the account associated with the API key owns that zone + +### Logging + +Enable debug logging in the gateway's logging configuration: + +```json +{ + "Logging": { + "LogLevel": { + "Keyfactor.Extensions.DomainValidator.LuaDns": "Debug" + } + } +} +``` + +### Service Status + +Check LuaDNS service status: https://www.luadns.com/ + +## Support + +The LuaDNS DNS Provider 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. + +### Resources + +- [LuaDNS Documentation](https://www.luadns.com/api.html) +- [Report Issues](https://github.com/Keyfactor/luadns-dnsplugin/issues) +- [Discussions](https://github.com/Keyfactor/luadns-dnsplugin/discussions) + +> 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. + +## License + +Apache License 2.0, see [LICENSE](LICENSE). + +## Related Integrations + +See all [Keyfactor DNS Provider plugins](https://github.com/orgs/Keyfactor/repositories?q=dnsplugin).