Developer guide — how a CA Gateway plugin and DNS provider plugins interact end-to-end
This document walks through the code-level integration between a Keyfactor CA Gateway plugin (e.g. the ACME CA plugin) and the DNS provider plugins that it delegates domain-control validation to. The AWS Route 53 DNS provider is used as the concrete example, but every provider plugin (Azure DNS, Cloudflare, NS1, RFC 2136, Infoblox, Google Cloud DNS) follows the same contract.
The CA Gateway plugin never talks to a DNS provider’s SDK directly. Instead, it depends on a small
set of interfaces from Keyfactor.AnyGateway.IAnyCAPlugin
that let the gateway runtime resolve the correct provider at runtime — per-domain — based on
whatever provider DLLs have been dropped into the gateway’s extension folder.
factory.ResolveDomainValidator(domain, challengeType).IDomainValidator implementation matching the domain’s zone.StageValidation() / CleanupValidation() on the validator.DomainValidationResult { Success, ErrorMessage }.
Three interfaces — all shipped in the Keyfactor.AnyGateway.IAnyCAPlugin
NuGet package — define the entire relationship:
| Interface | Implemented by | Purpose |
|---|---|---|
IDomainValidatorFactory | Gateway runtime | Resolves a validator given a domain + challenge type. |
IDomainValidator | Each DNS provider plugin | Stages / cleans up the DNS record for a challenge. |
IDomainValidatorConfigProvider | Gateway runtime | Delivers user-entered config to the validator at Initialize() time. |
The return shape is intentionally small:
public class DomainValidationResult
{
public bool Success { get; set; }
public string ErrorMessage { get; set; }
}
The CA plugin receives an IDomainValidatorFactory through constructor injection, then
resolves a validator per-domain during enrollment.
public class AcmeCaPlugin : IAnyCAPlugin
{
private readonly IDomainValidatorFactory _validatorFactory;
public AcmeCaPlugin(IDomainValidatorFactory validatorFactory)
{
_validatorFactory = validatorFactory ?? throw new ArgumentNullException(nameof(validatorFactory),
"IDomainValidatorFactory is required. DNS providers are externalized as plugins.");
}
}
var domain = authz.Identifier.Value;
var domainValidator = _validatorFactory.ResolveDomainValidator(domain, DNS_CHALLENGE_TYPE);
if (domainValidator == null)
{
throw new InvalidOperationException(
$"Failed to resolve domain validator for domain '{domain}'. " +
"Ensure the appropriate DNS provider plugin is deployed and configured.");
}
// Stage the DNS TXT record through whichever provider the gateway selected
var result = await domainValidator.StageValidation(
validation.DnsRecordName, // e.g. "_acme-challenge.example.com"
validation.DnsRecordValue, // the ACME-provided challenge token
CancellationToken.None);
if (!result.Success)
throw new InvalidOperationException(
$"Failed to stage DNS validation for {domain}: {result.ErrorMessage}");
Key insight: the CA plugin resolves a validator per domain, not per enrollment.
That is what allows a single cert request spanning api.example.com (Route 53) and
internal.corp (Infoblox) to work with zero special-casing in the CA plugin.
Regardless of whether the challenge succeeded, the CA plugin calls CleanupValidation
on each validator it used, so temporary TXT records don’t linger in the customer’s zone.
foreach (var (authz, challenge, validation, validator) in pendingChallenges)
{
try
{
await validator.CleanupValidation(validation.DnsRecordName, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup DNS record for {Domain}", domain);
// Continue cleanup for other domains even if one fails
}
}
A DNS provider plugin is a standalone .NET project that produces a single DLL plus a
manifest.json. No code in the CA plugin needs to change to add a new provider.
aws-route53-dnsplugin/
├── Keyfactor.DnsProvider.AwsRoute53/
│ ├── AwsRoute53DomainValidator.cs // implements IDomainValidator
│ ├── AwsRoute53DnsProvider.cs // wraps AWSSDK.Route53
│ └── Keyfactor.DnsProvider.AwsRoute53.csproj
├── manifest.json
└── integration-manifest.json
using Keyfactor.AnyGateway.Extensions;
namespace Keyfactor.Extensions.DomainValidator.AwsRoute53
{
public class AwsRoute53DomainValidator : IDomainValidator
{
private AwsRoute53DnsProvider _provider;
private Dictionary<string, object> _configuration;
public void Initialize(IDomainValidatorConfigProvider configProvider)
{
_configuration = configProvider.DomainValidationConfiguration;
string accessKey = GetConfigValue("AwsRoute53_AccessKey");
string secretKey = GetConfigValue("AwsRoute53_SecretKey");
_provider = new AwsRoute53DnsProvider(accessKey, secretKey);
}
public async Task<DomainValidationResult> StageValidation(
string key, string value, CancellationToken cancellationToken)
{
try
{
var success = await _provider.CreateRecordAsync(key, value);
return new DomainValidationResult
{
Success = success,
ErrorMessage = success ? null : $"Failed to create TXT record for {key}"
};
}
catch (Exception ex)
{
return new DomainValidationResult
{
Success = false,
ErrorMessage = $"Failed to create TXT record: {ex.Message}"
};
}
}
public async Task<DomainValidationResult> CleanupValidation(
string key, CancellationToken cancellationToken)
{
var success = await _provider.DeleteRecordAsync(key);
return new DomainValidationResult
{
Success = success,
ErrorMessage = success ? null : $"Failed to delete TXT record for {key}"
};
}
}
}
var request = new ChangeResourceRecordSetsRequest
{
HostedZoneId = zone.Id,
ChangeBatch = new ChangeBatch
{
Changes = new List<Change>
{
new Change
{
Action = ChangeAction.UPSERT,
ResourceRecordSet = new ResourceRecordSet
{
Name = EnsureTrailingDot(recordName),
Type = RRType.TXT,
TTL = 60,
ResourceRecords = new List<ResourceRecord>
{
new ResourceRecord { Value = $"\"{txtValue}\"" }
}
}
}
}
}
};
var response = await _route53Client.ChangeResourceRecordSetsAsync(request);
Each plugin declares its configuration surface so the gateway can render the fields in the Command UI:
public Dictionary<string, PropertyConfigInfo> GetDomainValidatorAnnotations()
{
return new Dictionary<string, PropertyConfigInfo>
{
["AwsRoute53_AccessKey"] = new PropertyConfigInfo
{
Comments = "AWS Route53: Access Key ID (Optional if using IAM role)",
Hidden = false,
DefaultValue = "",
Type = "Secret"
},
["AwsRoute53_SecretKey"] = new PropertyConfigInfo
{
Comments = "AWS Route53: Secret Access Key (Optional if using IAM role)",
Hidden = true,
DefaultValue = "",
Type = "Secret"
}
};
}
The gateway discovers plugins by scanning its extension folder for manifest.json files.
Each manifest names the plugin’s assembly, its implementation type, and its provider key —
everything the gateway needs to load the DLL by reflection and register it with the factory.
{
"pluginType": "DomainValidator",
"validationType": "DNS",
"providerName": "awsroute53",
"displayName": "AWS Route53 DNS",
"description": "DNS-01 challenge validation using AWS Route53",
"assemblyName": "AwsRoute53DomainValidator.dll",
"typeName": "Keyfactor.Extensions.DomainValidator.AwsRoute53.AwsRoute53DomainValidator",
"version": "1.0.0",
"author": "Keyfactor",
"configurationFields": [
"AwsRoute53_AccessKey",
"AwsRoute53_SecretKey"
]
}
<ItemGroup>
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.3.0-PRERELEASE-..." />
<PackageReference Include="AWSSDK.Core" Version="4.0.3.11" />
<PackageReference Include="AWSSDK.Route53" Version="4.0.8.8" />
<None Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
A full happy-path enrollment that spans a Route 53 domain and an Infoblox domain:
Keyfactor.AnyGateway.IAnyCAPlugin.IDomainValidator — Initialize, StageValidation,
CleanupValidation, GetValidationType, GetDomainValidatorAnnotations,
ValidateConfiguration.manifest.json with pluginType: "DomainValidator",
a unique providerName, the assemblyName, and the fully-qualified
typeName.manifest.json as CopyToOutputDirectory: Always in the csproj.GetDomainValidatorAnnotations become the form schema.Tip: use an existing plugin (e.g. AWS Route 53) as a template. Every provider
plugin in the Keyfactor ecosystem has the same skeleton — the only meaningful difference is
how StageValidation/CleanupValidation talk to the vendor’s API.