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
19 changes: 19 additions & 0 deletions docsource/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,22 @@ 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).

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

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.
158 changes: 154 additions & 4 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,164 @@ 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`).
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.

### 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://<step-ca-host>: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 @<internal-dns-server> 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 <internal-dns-server>`) 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 { <internal-dns-server>; };
};
```
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 <iface> 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 <your internal zone>` 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.
8 changes: 4 additions & 4 deletions test/smoke-test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading