Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
169 changes: 169 additions & 0 deletions Keyfactor.DnsProvider.LuaDns.Tests/LuaDnsCnameDomainValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
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());
}
}
}
}
32 changes: 32 additions & 0 deletions Keyfactor.DnsProvider.LuaDns/LuaDnsCnameDomainValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2025 Keyfactor
// Licensed under the Apache License, Version 2.0
namespace Keyfactor.Extensions.DomainValidator.LuaDns
{
/// <summary>
/// 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 <see cref="LuaDnsDomainValidator"/>.
/// </summary>
public class LuaDnsCnameDomainValidator : LuaDnsDomainValidatorBase
{
protected override string ValidationTypeName => "cname";

protected override string RecordTypeName => "CNAME";

/// <summary>
/// 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.&lt;zone&gt;", so the target is made absolute.
/// </summary>
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('.') + ".";
}
}
}
131 changes: 3 additions & 128 deletions Keyfactor.DnsProvider.LuaDns/LuaDnsDomainValidator.cs
Original file line number Diff line number Diff line change
@@ -1,141 +1,16 @@
// 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
{
/// <summary>
/// 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.
/// </summary>
public class LuaDnsDomainValidator : IDomainValidator
public class LuaDnsDomainValidator : LuaDnsDomainValidatorBase
{
private static readonly ILogger _logger = LogHandler.GetClassLogger<LuaDnsDomainValidator>();

private const string ValidationTypeName = "dns-01";
private const string RecordTypeName = "TXT";

private LuaDnsProvider _provider;
private Dictionary<string, object> _configuration;

public Dictionary<string, PropertyConfigInfo> GetDomainValidatorAnnotations()
{
return new Dictionary<string, PropertyConfigInfo>()
{
["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<DomainValidationResult> 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<DomainValidationResult> 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<string, object> 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";
}
}
Loading
Loading