DNS Provider Plugin Architecture

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.

1. Architectural Overview

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.

CA Plugin Gateway Runtime DNS Provider Plugin ┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────────────┐ │ AcmeCaPlugin │ │ IDomainValidator- │ │ AwsRoute53Domain- │ │ (IAnyCAPlugin) │─────▶│ Factory │────▶│ Validator │ │ │ (1) │ (injected DI-style) │ (2) │ (IDomainValidator) │ │ • Enroll │ │ │ │ • Initialize │ │ • ProcessAuths │◀─────│ │◀────│ • StageValidation │ └─────────────────────┘ (4) └─────────────────────┘ (3) │ • CleanupValidation │ │ • GetValidationType │ │ • GetDomainValidator- │ │ Annotations │ └───────────┬──────────────┘ │ ▼ ┌──────────────────────────┐ │ AwsRoute53DnsProvider │ │ (wraps AWSSDK.Route53) │ └───────────┬──────────────┘ │ ▼ ┌──────────────────────────┐ │ AWS Route 53 │ │ (TXT record) │ └──────────────────────────┘
  1. Resolve — the CA plugin calls factory.ResolveDomainValidator(domain, challengeType).
  2. Dispatch — the gateway returns the IDomainValidator implementation matching the domain’s zone.
  3. Work — the CA plugin calls StageValidation() / CleanupValidation() on the validator.
  4. Return — the validator returns a DomainValidationResult { Success, ErrorMessage }.

2. The Contract

Three interfaces — all shipped in the Keyfactor.AnyGateway.IAnyCAPlugin NuGet package — define the entire relationship:

InterfaceImplemented byPurpose
IDomainValidatorFactoryGateway runtimeResolves a validator given a domain + challenge type.
IDomainValidatorEach DNS provider pluginStages / cleans up the DNS record for a challenge.
IDomainValidatorConfigProviderGateway runtimeDelivers user-entered config to the validator at Initialize() time.

DomainValidationResult

The return shape is intentionally small:

public class DomainValidationResult
{
    public bool Success { get; set; }
    public string ErrorMessage { get; set; }
}

3. The CA Plugin Side (Caller)

The CA plugin receives an IDomainValidatorFactory through constructor injection, then resolves a validator per-domain during enrollment.

3.1 — Constructor injection

AcmeCaPlugin/AcmeCaPlugin.cs
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.");
    }
}

3.2 — Resolving the validator per-domain

AcmeCaPlugin/AcmeCaPlugin.cs — inside ProcessAuthorizations()
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.

3.3 — Cleanup

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
    }
}

4. The DNS Provider Plugin Side (AWS Route 53 Example)

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.

4.1 — Project layout

aws-route53-dnsplugin/
├── Keyfactor.DnsProvider.AwsRoute53/
│   ├── AwsRoute53DomainValidator.cs      // implements IDomainValidator
│   ├── AwsRoute53DnsProvider.cs          // wraps AWSSDK.Route53
│   └── Keyfactor.DnsProvider.AwsRoute53.csproj
├── manifest.json
└── integration-manifest.json

4.2 — The validator class

Keyfactor.DnsProvider.AwsRoute53/AwsRoute53DomainValidator.cs
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}"
            };
        }
    }
}

4.3 — The actual AWS SDK call (the only provider-specific bit)

Keyfactor.DnsProvider.AwsRoute53/AwsRoute53DnsProvider.cs
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);

4.4 — Declaring configurable properties

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"
        }
    };
}

5. Discovery: manifest.json

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.

manifest.json
{
  "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"
  ]
}

csproj wiring so the manifest ships with the DLL

<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>

6. End-to-End Sequence

A full happy-path enrollment that spans a Route 53 domain and an Infoblox domain:

CA Plugin Factory Route53 Plugin Infoblox Plugin ACME CA │ │ │ │ │ 1 Parse CSR → │ │ │ │ 2 CreateOrder ─────────────────────────────────────────────────────────────────────▶│ │ │ │ │ │ 3 For each domain: │ │ │ │ Resolve(api.ex.com)─▶│ │ │ │ │ │──returns──▶ Route53 │ │ │ StageValidation ─────────────────────────▶│ │ │ │ │ │ AWS SDK: UPSERT TXT │ │ │ │ │ │ │ Resolve(int.corp) ──▶│ │ │ │ │ │──returns──▶ Infoblox │ │ │ StageValidation ───────────────────────────────────────────────▶│ │ │ │ │ │ WAPI: POST TXT │ │ │ │ │ │ 4 Wait propagation │ │ │ │ 5 AnswerChallenge (both) ──────────────────────────────────────────────────────────▶│ 6 FinalizeOrder ──────────────────────────────────────────────────────────────────────▶│ 7 DownloadCert ◀────────────────────────────────────────────────────────────────────── │ │ │ │ │ │ 8 CleanupValidation (each validator) │

7. Why This Shape

8. Building a New DNS Provider — Checklist

  1. New .NET class library project targeting the same TFM as the gateway.
  2. Reference Keyfactor.AnyGateway.IAnyCAPlugin.
  3. Implement IDomainValidator — Initialize, StageValidation, CleanupValidation, GetValidationType, GetDomainValidatorAnnotations, ValidateConfiguration.
  4. Add a manifest.json with pluginType: "DomainValidator", a unique providerName, the assemblyName, and the fully-qualified typeName.
  5. Mark manifest.json as CopyToOutputDirectory: Always in the csproj.
  6. Build — drop the output DLL + manifest into the gateway’s extension folder — restart the gateway.
  7. Configure the plugin’s fields in the Command UI — the annotations you returned from 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.