From 472c11fc02214d3f0d797a43725b65a608df8127 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 21 Jul 2026 15:49:28 -0400 Subject: [PATCH 1/4] test harness --- .../Keyfactor.DnsProvider.MicrosoftAD.csproj | 5 ++ .../ManualTestHarness.csproj | 16 ++++ test/ManualTestHarness/Program.cs | 85 +++++++++++++++++++ test/README.md | 79 +++++++++++++++++ test/smoke-test.ps1 | 73 ++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 test/ManualTestHarness/ManualTestHarness.csproj create mode 100644 test/ManualTestHarness/Program.cs create mode 100644 test/README.md create mode 100644 test/smoke-test.ps1 diff --git a/Keyfactor.DnsProvider.MicrosoftAD/Keyfactor.DnsProvider.MicrosoftAD.csproj b/Keyfactor.DnsProvider.MicrosoftAD/Keyfactor.DnsProvider.MicrosoftAD.csproj index 472006c..5c57943 100644 --- a/Keyfactor.DnsProvider.MicrosoftAD/Keyfactor.DnsProvider.MicrosoftAD.csproj +++ b/Keyfactor.DnsProvider.MicrosoftAD/Keyfactor.DnsProvider.MicrosoftAD.csproj @@ -10,6 +10,11 @@ Microsoft Windows Server DNS (Active Directory) domain validation provider for the Keyfactor AnyCA Gateway (DNS-01 TXT and CNAME DCV), via WinRM remote PowerShell. + + + + + diff --git a/test/ManualTestHarness/ManualTestHarness.csproj b/test/ManualTestHarness/ManualTestHarness.csproj new file mode 100644 index 0000000..f4d8f06 --- /dev/null +++ b/test/ManualTestHarness/ManualTestHarness.csproj @@ -0,0 +1,16 @@ + + + Exe + + net8.0-windows + enable + disable + ManualTestHarness + ManualTestHarness + + + + + + + diff --git a/test/ManualTestHarness/Program.cs b/test/ManualTestHarness/Program.cs new file mode 100644 index 0000000..31a4be4 --- /dev/null +++ b/test/ManualTestHarness/Program.cs @@ -0,0 +1,85 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 +// +// Manual test harness for the Microsoft AD DNS provider. Drives the internal +// MicrosoftAdDnsProvider directly (no gateway required) so you can exercise the +// real WinRM + DnsServer-cmdlet code path against a live Windows DNS server. +// +// This is a LIVE test: it creates and then deletes records in a real zone. +// Point it at a lab/test zone, not production. +// +// Configure with environment variables (see test/README.md), then: +// dotnet run --project test/ManualTestHarness + +using Keyfactor.Extensions.DomainValidator.MicrosoftAD; + +static string Env(string name, string fallback = null) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; + +var dnsServer = Env("AD_DnsServer"); +var username = Env("AD_Username"); // optional; empty => gateway/current identity via Negotiate +var password = Env("AD_Password"); // optional +var zone = Env("AD_Zone"); // optional; empty => auto suffix-match +var useSsl = string.Equals(Env("AD_UseSSL", "false"), "true", StringComparison.OrdinalIgnoreCase); + +// A record name that must be covered by a hosted zone on the server (or by AD_Zone). +// Default assumes a lab zone "example.test" exists on the server. +var testDomain = Env("TEST_DOMAIN", "example.test"); + +if (string.IsNullOrWhiteSpace(dnsServer)) +{ + Console.Error.WriteLine("AD_DnsServer is required. Set it (and optionally AD_Username/AD_Password/AD_Zone/AD_UseSSL/TEST_DOMAIN)."); + return 2; +} + +var txtName = $"_acme-challenge.{testDomain}"; +var txtValue = "harness-txt-" + Guid.NewGuid().ToString("N")[..16]; +var cnameName = $"_dcv-test.{testDomain}"; +var cnameValue = $"{Guid.NewGuid():N}.dcv.example-ca.test"; + +Console.WriteLine($"DNS server : {dnsServer} (SSL={useSsl})"); +Console.WriteLine($"Identity : {(string.IsNullOrWhiteSpace(username) ? "" : username)}"); +Console.WriteLine($"Zone : {(string.IsNullOrWhiteSpace(zone) ? "" : zone)}"); +Console.WriteLine($"Test domain: {testDomain}"); +Console.WriteLine(); + +var provider = new MicrosoftAdDnsProvider(dnsServer, username, password, zone, useSsl); +var failures = 0; + +async Task Step(string label, Func action) +{ + Console.Write($" {label,-42} "); + try { await action(); Console.WriteLine("OK"); } + catch (Exception ex) { failures++; Console.WriteLine($"FAIL\n -> {ex.Message}"); } +} + +// 1) Connectivity / permissions: lists zones on the server. +await Step("ValidateConnection (list zones)", () => provider.ValidateConnectionAsync()); + +// 2) TXT (dns-01) lifecycle. +await Step($"Create TXT {txtName}", () => provider.CreateRecordAsync(txtName, txtValue, "TXT")); +Console.WriteLine($" value: {txtValue}"); +Console.WriteLine($" verify: nslookup -type=TXT {txtName} {dnsServer}"); +await Step("Delete TXT (exact value)", () => provider.DeleteRecordAsync(txtName, txtValue, "TXT")); + +// 3) TXT additive behavior: two values coexist at the same name, then targeted delete. +var txtValue2 = "harness-txt-" + Guid.NewGuid().ToString("N")[..16]; +await Step("Create TXT value #1", () => provider.CreateRecordAsync(txtName, txtValue, "TXT")); +await Step("Create TXT value #2 (additive)", () => provider.CreateRecordAsync(txtName, txtValue2, "TXT")); +Console.WriteLine($" expect BOTH via: nslookup -type=TXT {txtName} {dnsServer}"); +await Step("Delete only TXT value #1", () => provider.DeleteRecordAsync(txtName, txtValue, "TXT")); +Console.WriteLine($" expect ONLY #2 remains: {txtValue2}"); +await Step("Delete remaining TXT (all)", () => provider.DeleteRecordAsync(txtName, null, "TXT")); + +// 4) CNAME (cname DCV) lifecycle. +await Step($"Create CNAME {cnameName}", () => provider.CreateRecordAsync(cnameName, cnameValue, "CNAME")); +Console.WriteLine($" alias: {cnameValue}"); +Console.WriteLine($" verify: nslookup -type=CNAME {cnameName} {dnsServer}"); +await Step("Delete CNAME", () => provider.DeleteRecordAsync(cnameName, null, "CNAME")); + +// 5) Idempotent cleanup: deleting a non-existent record is treated as success. +await Step("Delete missing TXT (idempotent)", () => provider.DeleteRecordAsync($"_nope.{testDomain}", null, "TXT")); + +Console.WriteLine(); +Console.WriteLine(failures == 0 ? "ALL STEPS PASSED" : $"{failures} STEP(S) FAILED"); +return failures == 0 ? 0 : 1; diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..57d107f --- /dev/null +++ b/test/README.md @@ -0,0 +1,79 @@ +# Testing the Microsoft AD DNS Provider + +The part worth testing is the WinRM → `DnsServer` cmdlet logic in +`MicrosoftAdDnsProvider`. The two validator classes are thin glue over it. So the +setup here drives that provider against a **real Windows DNS server** over WinRM — +no public internet, no CA, no full gateway required. + +> **Internal-only.** Testing the plugin needs only LAN reachability from the test +> machine to your DNS server (WinRM, TCP 5985). Whether AD is reachable "from the +> outside" matters only for a real CA to *see* the record during actual cert +> issuance — a separate concern from testing this code. You verify results by +> querying the DNS server directly (`nslookup ... `). + +## What you need (lab) + +1. A Windows Server with the **DNS Server role** hosting a throwaway forward-lookup + zone, e.g. `example.test`. A domain controller works; a standalone DNS server + works too. **Use a test zone, not production** — the harness writes and deletes + records. +2. **WinRM enabled** on that server and reachable from the test machine: + ```powershell + # On the DNS server (elevated): + Enable-PSRemoting -Force + ``` + If the test machine is not domain-joined / not in the same domain, on the + *test* machine add the server to TrustedHosts (or use `-UseSsl`): + ```powershell + Set-Item WSMan:\localhost\Client\TrustedHosts -Value dc01.corp.example.test -Concatenate + ``` +3. An identity in **DnsAdmins** (or Domain Admins), or delegated DNS management on + the zone — either your current logon, or an explicit `AD_Username`/`AD_Password`. + +## Step 1 — Infra smoke test (no code) + +Run this first from the test machine. It exercises the exact prerequisites the +plugin depends on, so a failure here is an environment problem, not a code problem. + +```powershell +cd test +.\smoke-test.ps1 -DnsServer dc01.corp.example.test -Zone example.test +# cross-domain / explicit creds: +.\smoke-test.ps1 -DnsServer dc01.corp.example.test -Zone example.test -Credential (Get-Credential) +``` + +## Step 2 — Provider harness (the real code) + +Drives `MicrosoftAdDnsProvider` directly: connection check, TXT create/delete, +TXT additive-coexistence, targeted-value delete, CNAME create/delete, and +idempotent cleanup of a missing record. + +```powershell +$env:AD_DnsServer = "dc01.corp.example.test" +$env:TEST_DOMAIN = "example.test" # must be covered by a hosted zone (or set AD_Zone) +# optional: +# $env:AD_Username = "CORP\svc-keyfactor" +# $env:AD_Password = "..." +# $env:AD_Zone = "example.test" +# $env:AD_UseSSL = "true" + +dotnet run --project test/ManualTestHarness +``` + +Each step prints `OK`/`FAIL`; the process exits non-zero if any step failed. The +output includes `nslookup` commands so you can independently confirm records +appear and disappear on the server. + +## Step 3 — Full gateway integration (optional, end-to-end) + +Only needed to prove issuance with a real CA, and only meaningful if the zone is +publicly resolvable: + +1. Build the plugin (`dotnet build -c Release`) and copy the `net8.0` output into + the gateway's `Extensions` folder (see the root `README.md`). +2. Restart the AnyGatewayREST service. +3. In the gateway UI, add a Domain Validation entry, pick **Microsoft Active + Directory DNS** (`MicrosoftAdDomainValidator` for TXT / `MicrosoftAdCnameDomainValidator` + for CNAME), fill in the `AD_*` fields, and map it to the domain. +4. Enroll a cert for that domain and watch the gateway stage → CA validate → + cleanup. diff --git a/test/smoke-test.ps1 b/test/smoke-test.ps1 new file mode 100644 index 0000000..761b21f --- /dev/null +++ b/test/smoke-test.ps1 @@ -0,0 +1,73 @@ +# Copyright 2025 Keyfactor +# Licensed under the Apache License, Version 2.0 +# +# Pre-flight smoke test for the Microsoft AD DNS provider. +# Verifies the SAME environment the plugin needs — WinRM remote PowerShell to the +# DNS server plus the DnsServer module cmdlets — WITHOUT the plugin or the gateway. +# Run this from the machine that will host the gateway (or your dev box) BEFORE the +# .NET harness, so you can tell infrastructure problems apart from code problems. +# +# Example: +# .\smoke-test.ps1 -DnsServer dc01.corp.example.test -Zone example.test +# .\smoke-test.ps1 -DnsServer dc01 -Zone example.test -Credential (Get-Credential) + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $DnsServer, + [Parameter(Mandatory = $true)] [string] $Zone, + [System.Management.Automation.PSCredential] $Credential, + [switch] $UseSsl +) + +$ErrorActionPreference = 'Stop' +$sessionArgs = @{ ComputerName = $DnsServer } +if ($Credential) { $sessionArgs.Credential = $Credential } +if ($UseSsl) { $sessionArgs.UseSSL = $true } + +Write-Host "1. Opening WinRM session to $DnsServer ..." -NoNewline +$session = New-PSSession @sessionArgs +Write-Host " OK" + +try { + Write-Host "2. DnsServer module present on target ..." -NoNewline + $hasModule = Invoke-Command -Session $session { [bool](Get-Module -ListAvailable DnsServer) } + if (-not $hasModule) { throw "DnsServer module NOT found — install the DNS Server role/RSAT on $DnsServer." } + Write-Host " OK" + + Write-Host "3. Zone '$Zone' is hosted on target ..." -NoNewline + $zoneOk = Invoke-Command -Session $session -ArgumentList $Zone { + param($z) [bool](Get-DnsServerZone -Name $z -ErrorAction SilentlyContinue) + } + if (-not $zoneOk) { throw "Zone '$Zone' is not hosted on $DnsServer." } + Write-Host " OK" + + $rel = "_smoketest" + $val = "smoke-$([guid]::NewGuid().ToString('N').Substring(0,12))" + + Write-Host "4. Add TXT $rel.$Zone ..." -NoNewline + Invoke-Command -Session $session -ArgumentList $Zone, $rel, $val { + param($z, $r, $v) + Add-DnsServerResourceRecord -ZoneName $z -Name $r -Txt -DescriptiveText $v -TimeToLive ([TimeSpan]::FromSeconds(60)) + } + Write-Host " OK" + + Write-Host "5. Read it back ..." -NoNewline + $read = Invoke-Command -Session $session -ArgumentList $Zone, $rel { + param($z, $r) + (Get-DnsServerResourceRecord -ZoneName $z -Name $r -RRType Txt).RecordData.DescriptiveText + } + if ($read -notcontains $val) { throw "TXT value not found after add (got: $read)." } + Write-Host " OK ($read)" + + Write-Host "6. Remove TXT ..." -NoNewline + Invoke-Command -Session $session -ArgumentList $Zone, $rel, $val { + param($z, $r, $v) + Remove-DnsServerResourceRecord -ZoneName $z -Name $r -RRType Txt -RecordData $v -Force -Confirm:$false + } + Write-Host " OK" + + Write-Host "`nSMOKE TEST PASSED — the plugin's environment prerequisites are satisfied." -ForegroundColor Green +} +finally { + Remove-PSSession $session +} From 09600431d92d5feaf58827604dec250b862ebc66 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 18 Aug 2026 14:00:16 -0400 Subject: [PATCH 2/4] Document end-to-end testing, including private ACME CA setup for internal zones Adds a Testing section covering the three test levels (smoke test, provider harness, full gateway integration) and a detailed step-ca walkthrough for testing DNS-01 validation against internal-only zones, since public ACME CAs reject non-public suffixes outright. Also fixes smoke-test.ps1 em-dash characters that broke parsing on Windows PowerShell 5.1 with mismatched console encoding. --- README.md | 17 +++++ docsource/content.md | 17 +++++ test/README.md | 158 +++++++++++++++++++++++++++++++++++++++++-- test/smoke-test.ps1 | 8 +-- 4 files changed, 192 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3becce6..ab03e1c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ The owning zone for a given FQDN is resolved by listing the server's forward-loo * Each validator type manages only its own record type: `MicrosoftAdDomainValidator` reads/writes `TXT`, `MicrosoftAdCnameDomainValidator` reads/writes `CNAME`. Neither touches other record types. * The DNS server must have the `DnsServer` PowerShell module available (installed with the DNS Server role). +## Testing + +There are three levels of testing, each isolating a different layer of the stack. See [test/README.md](test/README.md) for full details; summarized here: + +1. **Infra smoke test** (`test/smoke-test.ps1`) — pure PowerShell, no plugin code. Confirms WinRM reachability, the `DnsServer` module, and the target zone from the machine that will host the gateway. +2. **Provider harness** (`test/ManualTestHarness`) — drives `MicrosoftAdDnsProvider` directly (no gateway, no CA). Exercises TXT create/delete, additive multi-value TXT, targeted delete, CNAME create/delete, and idempotent cleanup against a real DNS server. +3. **Full gateway + CA integration** — a real enrollment through the gateway, a CA, and this plugin together. This is the only level that proves the domain validator is wired up correctly end-to-end (gateway config → CA → DNS-01 challenge → this plugin → DNS server → CA re-check → issuance). + +### Level 3 against an internal-only zone (e.g. Active Directory `.local` / `.corp`) + +Public ACME CAs (Let's Encrypt, Google Trust Services, etc.) **reject internal/non-public zones outright** — the order fails at `CreateOrder` with `rejectedIdentifier` / `"Domain must end in a public suffix"` before DNS validation is ever attempted, because `.local`-style names aren't ICANN-delegated public suffixes. This is a CA-side policy check, not a DNS or plugin problem, and it means a public CA can never be used to test this plugin against an internal AD zone. + +To test level 3 against an internal zone (e.g. `command.local`), point the gateway's CA connector at a **private ACME server** instead — [step-ca](https://smallstep.com/docs/step-ca/) works well and doesn't enforce public-suffix rules. See [test/README.md](test/README.md#step-3b--full-gateway-integration-against-an-internal-zone-with-a-private-acme-ca-step-ca) for a full step-ca setup and DNS-resolution troubleshooting walkthrough, including two gotchas that are easy to lose time to: + +* The gateway's own DNS-propagation pre-check defaults to public resolvers (8.8.8.8, 1.1.1.1, etc.), which can never see an internal zone. Point it at an internal DNS server via the CA connector's `DnsVerificationServer` setting. +* The ACME server itself (step-ca) does its **own independent** DNS lookup when validating the challenge — it must be able to resolve the internal zone through its own OS-level resolver, entirely separately from whether the gateway or this plugin can. A DNS-01 order can appear to stage and submit correctly and still hang at `pending` forever if the ACME server's host can't resolve the internal zone. + ### Runtime Requirements - .NET 10.0 runtime (provided by the gateway server) diff --git a/docsource/content.md b/docsource/content.md index bf6ebea..fb9a597 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -54,3 +54,20 @@ The owning zone for a given FQDN is resolved by listing the server's forward-loo * Zones are discovered from the target server's hosted forward-lookup zones only; a record whose domain is not covered by a hosted zone (and no `AD_Zone` override is set) fails with `No DNS zone hosted on server ... covers record`. * Each validator type manages only its own record type: `MicrosoftAdDomainValidator` reads/writes `TXT`, `MicrosoftAdCnameDomainValidator` reads/writes `CNAME`. Neither touches other record types. * The DNS server must have the `DnsServer` PowerShell module available (installed with the DNS Server role). + +## Testing + +There are three levels of testing, each isolating a different layer of the stack. See [test/README.md](test/README.md) for full details; summarized here: + +1. **Infra smoke test** (`test/smoke-test.ps1`) — pure PowerShell, no plugin code. Confirms WinRM reachability, the `DnsServer` module, and the target zone from the machine that will host the gateway. +2. **Provider harness** (`test/ManualTestHarness`) — drives `MicrosoftAdDnsProvider` directly (no gateway, no CA). Exercises TXT create/delete, additive multi-value TXT, targeted delete, CNAME create/delete, and idempotent cleanup against a real DNS server. +3. **Full gateway + CA integration** — a real enrollment through the gateway, a CA, and this plugin together. This is the only level that proves the domain validator is wired up correctly end-to-end (gateway config → CA → DNS-01 challenge → this plugin → DNS server → CA re-check → issuance). + +### Level 3 against an internal-only zone (e.g. Active Directory `.local` / `.corp`) + +Public ACME CAs (Let's Encrypt, Google Trust Services, etc.) **reject internal/non-public zones outright** — the order fails at `CreateOrder` with `rejectedIdentifier` / `"Domain must end in a public suffix"` before DNS validation is ever attempted, because `.local`-style names aren't ICANN-delegated public suffixes. This is a CA-side policy check, not a DNS or plugin problem, and it means a public CA can never be used to test this plugin against an internal AD zone. + +To test level 3 against an internal zone (e.g. `command.local`), point the gateway's CA connector at a **private ACME server** instead — [step-ca](https://smallstep.com/docs/step-ca/) works well and doesn't enforce public-suffix rules. See [test/README.md](test/README.md#step-3b--full-gateway-integration-against-an-internal-zone-with-a-private-acme-ca-step-ca) for a full step-ca setup and DNS-resolution troubleshooting walkthrough, including two gotchas that are easy to lose time to: + +* The gateway's own DNS-propagation pre-check defaults to public resolvers (8.8.8.8, 1.1.1.1, etc.), which can never see an internal zone. Point it at an internal DNS server via the CA connector's `DnsVerificationServer` setting. +* The ACME server itself (step-ca) does its **own independent** DNS lookup when validating the challenge — it must be able to resolve the internal zone through its own OS-level resolver, entirely separately from whether the gateway or this plugin can. A DNS-01 order can appear to stage and submit correctly and still hang at `pending` forever if the ACME server's host can't resolve the internal zone. diff --git a/test/README.md b/test/README.md index 58816cb..4372a74 100644 --- a/test/README.md +++ b/test/README.md @@ -66,8 +66,15 @@ appear and disappear on the server. ## Step 3 — Full gateway integration (optional, end-to-end) -Only needed to prove issuance with a real CA, and only meaningful if the zone is -publicly resolvable: +Two variants, depending on whether the zone you're testing is publicly resolvable: + +- **3a** — real public CA, publicly-delegated zone. +- **3b** — private ACME CA (step-ca), internal-only zone (e.g. an AD `.local`/`.corp` + zone). This is the one worth reading closely — several of the failures below look + like DNS or plugin bugs but are actually environment/config gaps specific to + testing against a private CA and an internal zone. + +Common setup for both: 1. Build the plugin (`dotnet build -c Release`) and copy the `net10.0` output into the gateway's `Extensions` folder (see the root `README.md`). @@ -75,5 +82,148 @@ publicly resolvable: 3. In the gateway UI, add a Domain Validation entry, pick **Microsoft Active Directory DNS** (`MicrosoftAdDomainValidator` for TXT / `MicrosoftAdCnameDomainValidator` for CNAME), fill in the `AD_*` fields, and map it to the domain. -4. Enroll a cert for that domain and watch the gateway stage → CA validate → - cleanup. + +### Step 3a — Full gateway integration, public CA / publicly-resolvable zone + +Only meaningful if the zone is actually publicly resolvable (real domain, real NS +delegation to a DNS server the CA can query). Point the gateway's CA connector at +the public CA's ACME directory (e.g. Let's Encrypt, Google Trust Services), then +enroll a cert for the domain and watch the gateway stage → CA validate → cleanup. + +If you don't have a public domain to spare, you can carve out a throwaway +subdomain of one you own and delegate just that subdomain (via NS records at your +registrar) to the Windows DNS server under test, rather than exposing your whole +domain or a production DNS server. **Exposing DNS (port 53) publicly on a domain +controller is a real security tradeoff** — prefer a dedicated, non-DC standalone +DNS server for the delegated subdomain if you go this route, and close the port +again once you're done testing. + +**Public CAs reject internal/non-public zones outright.** If you try to enroll for +a name under an internal zone (e.g. `bri.command.local`) against a public CA, the +order fails immediately at `CreateOrder`: + +``` +urn:ietf:params:acme:error:rejectedIdentifier — "Domain must end in a public suffix." +``` + +This is a CA-side policy check (ICANN public-suffix list), not a DNS or plugin +problem — no amount of DNS troubleshooting will fix it. If your zone is internal, +skip straight to 3b. + +### Step 3b — Full gateway integration against an internal zone, with a private ACME CA (step-ca) + +To exercise the real gateway → CA → DNS-01 challenge → this plugin → DNS server → +CA re-check → issuance flow against an internal-only zone, run a private ACME +server instead of a public CA. [step-ca](https://smallstep.com/docs/step-ca/) is a +good fit — it speaks real ACME and doesn't enforce the public-suffix check. + +#### Set up step-ca + +On a Linux box reachable from the gateway (same subnet is simplest): + +```bash +step ca init --name "Lab ACME CA" --dns step-ca-host.example --address :8443 --provisioner acme +step ca provisioner add acme --type ACME +``` + +Run it persistently (a plain foreground run dies the moment your SSH session does +or you hit Ctrl+C — easy to lose an hour to before noticing the CA silently went +away and every subsequent enrollment gets "connection actively refused"): + +```bash +nohup step-ca ~/.step/config/ca.json --password-file ~/.step/secrets/password.txt > ~/stepca.log 2>&1 & +disown +ss -tlnp | grep 8443 # confirm it's actually listening +curl -sk https://localhost:8443/health +``` + +For production-like persistence, prefer the `step-ca` systemd unit +(`sudo systemctl enable --now step-ca`) if your install provides one. + +#### Wire the gateway to step-ca + +1. Get the ACME directory URL: `https://:8443/acme/acme/directory`. +2. Trust step-ca's root cert on the gateway host (`step ca root` on the step-ca + box, then import into `Cert:\LocalMachine\Root` on the gateway) — otherwise the + gateway's ACME client fails TLS validation against the self-signed lab root. +3. In the gateway's Certificate Authorities config, add/edit a CA entry pointing + `DirectoryUrl` at that address. +4. **Set `DnsVerificationServer`** on the same CA config to your internal DNS + server's IP (e.g. the domain controller from Step 1/2). This field defaults to + empty, which makes the gateway's own DNS-propagation pre-check fall back to + public resolvers (8.8.8.8, 1.1.1.1, etc.) — which can never see an internal + zone, so propagation "verification" always reports `0/N servers confirmed` and + the gateway proceeds on a blind fallback delay instead of a real check. Setting + this field to the internal DNS server fixes that pre-check. + +#### The gotcha that actually blocks internal-zone testing: step-ca's own DNS resolution + +Even with the gateway's propagation pre-check fixed, an enrollment can still hang: +`StageValidation` and `SubmitChallenge` succeed, but the ACME order sits at +`pending` forever and eventually times out with `CertificateNotReady`. This is +because **step-ca does its own, completely independent DNS lookup** when it +validates the challenge — the fact that the gateway (or this plugin, or your own +`dig`/`nslookup` from elsewhere) can resolve the internal zone says nothing about +whether the machine step-ca itself is running on can. + +Diagnose on the step-ca host: + +```bash +dig SOA command.local # through the system resolver, as step-ca would see it +dig @ SOA command.local # direct query, bypassing the system resolver +``` + +If the direct query works but the plain `dig` doesn't, the step-ca host's own DNS +resolution — not network reachability — is the problem. A few things that can +cause this, roughly in the order we hit them testing this plugin: + +- **systemd-resolved split-DNS routing domains didn't take effect.** Setting a + per-link routing domain (`resolvectl domain eth0 "~command.local"` plus + `resolvectl dns eth0 `) looked correct in `resolvectl + status` but queries still went out to the public resolver path and NXDOMAIN'd + (`.local` isn't a delegated public TLD, so a leaked public lookup always fails + this way — a giveaway that routing isn't actually being honored). Don't trust + that the config "looks right"; verify with an actual `dig` for a name you know + exists in the zone (e.g. the zone's own SOA). +- **A local caching resolver (BIND, `dnsmasq`, etc.) already running on the box for + another purpose can be repurposed as a reliable fix.** Add a forward zone + pointing the internal zone at the internal DNS server: + ``` + zone "command.local" { + type forward; + forward only; + forwarders { ; }; + }; + ``` + then `systemctl restart bind9` (or your resolver of choice) and test with + `dig @127.0.0.1 SOA command.local`. +- **DNSSEC validation on the forwarder breaks unsigned internal zones.** BIND's + default `dnssec-validation auto;`/`yes;` tries to build a trust chain for every + forwarded response, including the internal zone — which almost certainly isn't + DNSSEC-signed (typical for AD-integrated DNS) — so validation fails and BIND + returns `SERVFAIL` instead of passing the answer through. The log line that + confirms this specific cause: `insecurity proof failed resolving + 'command.local/SOA/IN'`. Fix by adding `dnssec-validation no;` to the resolver's + options and restarting it. Acceptable for a lab; know what you're doing before + doing this anywhere production-adjacent. +- **Getting the OS to actually use the fixed resolver can itself be unreliable.** + Even after BIND was confirmed correct via `dig @127.0.0.1 ...`, re-pointing the + interface's default resolver via `resolvectl dns 127.0.0.1` still didn't + take effect for plain `dig SOA command.local` (queries kept leaking to the + public path) — on this host, `eth0` had `-DefaultRoute` set, meaning it's only + used for domains matching its own routing domains, not as the general fallback. + The reliable fix was to bypass `systemd-resolved`'s stub resolver entirely: + ```bash + sudo unlink /etc/resolv.conf + echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf + dig SOA command.local # confirm it now resolves through the fixed local resolver + ``` + This breaks `systemd-resolved`'s management of `/etc/resolv.conf` (a static file + instead of its managed symlink) — fine for a disposable lab box, not something to + do on a host you need standard `systemd-resolved` behavior on long-term. + +Once `dig SOA ` resolves correctly through the plain system +resolver (no explicit `@server`) on the step-ca host, retry the enrollment. A +successful run's `[FLOW:Enroll:CN=...]` trace should show the DNS verification +step reporting `N/N servers confirmed record` (not `0/N`), and the order should +progress from `pending` through `ready` to issuance instead of timing out. diff --git a/test/smoke-test.ps1 b/test/smoke-test.ps1 index 761b21f..166cc8b 100644 --- a/test/smoke-test.ps1 +++ b/test/smoke-test.ps1 @@ -2,8 +2,8 @@ # Licensed under the Apache License, Version 2.0 # # Pre-flight smoke test for the Microsoft AD DNS provider. -# Verifies the SAME environment the plugin needs — WinRM remote PowerShell to the -# DNS server plus the DnsServer module cmdlets — WITHOUT the plugin or the gateway. +# Verifies the SAME environment the plugin needs - WinRM remote PowerShell to the +# DNS server plus the DnsServer module cmdlets - WITHOUT the plugin or the gateway. # Run this from the machine that will host the gateway (or your dev box) BEFORE the # .NET harness, so you can tell infrastructure problems apart from code problems. # @@ -31,7 +31,7 @@ Write-Host " OK" try { Write-Host "2. DnsServer module present on target ..." -NoNewline $hasModule = Invoke-Command -Session $session { [bool](Get-Module -ListAvailable DnsServer) } - if (-not $hasModule) { throw "DnsServer module NOT found — install the DNS Server role/RSAT on $DnsServer." } + if (-not $hasModule) { throw "DnsServer module NOT found - install the DNS Server role/RSAT on $DnsServer." } Write-Host " OK" Write-Host "3. Zone '$Zone' is hosted on target ..." -NoNewline @@ -66,7 +66,7 @@ try { } Write-Host " OK" - Write-Host "`nSMOKE TEST PASSED — the plugin's environment prerequisites are satisfied." -ForegroundColor Green + Write-Host "`nSMOKE TEST PASSED - the plugin's environment prerequisites are satisfied." -ForegroundColor Green } finally { Remove-PSSession $session From 1ffddd2eb3e018c551b9743f5893c8e168236736 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Aug 2026 18:01:02 +0000 Subject: [PATCH 3/4] docs: auto-generate README and documentation [skip ci] --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index ab03e1c..3becce6 100644 --- a/README.md +++ b/README.md @@ -101,23 +101,6 @@ The owning zone for a given FQDN is resolved by listing the server's forward-loo * Each validator type manages only its own record type: `MicrosoftAdDomainValidator` reads/writes `TXT`, `MicrosoftAdCnameDomainValidator` reads/writes `CNAME`. Neither touches other record types. * The DNS server must have the `DnsServer` PowerShell module available (installed with the DNS Server role). -## Testing - -There are three levels of testing, each isolating a different layer of the stack. See [test/README.md](test/README.md) for full details; summarized here: - -1. **Infra smoke test** (`test/smoke-test.ps1`) — pure PowerShell, no plugin code. Confirms WinRM reachability, the `DnsServer` module, and the target zone from the machine that will host the gateway. -2. **Provider harness** (`test/ManualTestHarness`) — drives `MicrosoftAdDnsProvider` directly (no gateway, no CA). Exercises TXT create/delete, additive multi-value TXT, targeted delete, CNAME create/delete, and idempotent cleanup against a real DNS server. -3. **Full gateway + CA integration** — a real enrollment through the gateway, a CA, and this plugin together. This is the only level that proves the domain validator is wired up correctly end-to-end (gateway config → CA → DNS-01 challenge → this plugin → DNS server → CA re-check → issuance). - -### Level 3 against an internal-only zone (e.g. Active Directory `.local` / `.corp`) - -Public ACME CAs (Let's Encrypt, Google Trust Services, etc.) **reject internal/non-public zones outright** — the order fails at `CreateOrder` with `rejectedIdentifier` / `"Domain must end in a public suffix"` before DNS validation is ever attempted, because `.local`-style names aren't ICANN-delegated public suffixes. This is a CA-side policy check, not a DNS or plugin problem, and it means a public CA can never be used to test this plugin against an internal AD zone. - -To test level 3 against an internal zone (e.g. `command.local`), point the gateway's CA connector at a **private ACME server** instead — [step-ca](https://smallstep.com/docs/step-ca/) works well and doesn't enforce public-suffix rules. See [test/README.md](test/README.md#step-3b--full-gateway-integration-against-an-internal-zone-with-a-private-acme-ca-step-ca) for a full step-ca setup and DNS-resolution troubleshooting walkthrough, including two gotchas that are easy to lose time to: - -* The gateway's own DNS-propagation pre-check defaults to public resolvers (8.8.8.8, 1.1.1.1, etc.), which can never see an internal zone. Point it at an internal DNS server via the CA connector's `DnsVerificationServer` setting. -* The ACME server itself (step-ca) does its **own independent** DNS lookup when validating the challenge — it must be able to resolve the internal zone through its own OS-level resolver, entirely separately from whether the gateway or this plugin can. A DNS-01 order can appear to stage and submit correctly and still hang at `pending` forever if the ACME server's host can't resolve the internal zone. - ### Runtime Requirements - .NET 10.0 runtime (provided by the gateway server) From d9259a348cd348687ace81903b5b09a1b9cea668 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 18 Aug 2026 14:11:54 -0400 Subject: [PATCH 4/4] Nest Testing content under Usage so the doc tool picks it up The auto-doc tool only merges recognized top-level headings from content.md (Overview, Requirements) into the generated README; a standalone Testing heading was silently dropped on the last run. Moving it under Usage, which the tool already renders content for. --- docsource/content.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docsource/content.md b/docsource/content.md index fb9a597..66a6173 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -55,7 +55,9 @@ The owning zone for a given FQDN is resolved by listing the server's forward-loo * Each validator type manages only its own record type: `MicrosoftAdDomainValidator` reads/writes `TXT`, `MicrosoftAdCnameDomainValidator` reads/writes `CNAME`. Neither touches other record types. * The DNS server must have the `DnsServer` PowerShell module available (installed with the DNS Server role). -## Testing +## Usage + +### Testing There are three levels of testing, each isolating a different layer of the stack. See [test/README.md](test/README.md) for full details; summarized here: