Skip to content

Commit de91780

Browse files
committed
test(gateway): validate the dns-persist-01 record against the CA, not around it
The phase issued a certificate but proved nothing about the record: Pebble ran with PEBBLE_VA_ALWAYS_VALID, so it accepted a challenge it never looked up. Everything the record's grammar has to satisfy -- the `; ` separator, the issuer name the CA answers to, `policy=wildcard` covering the base name -- was pinned only by unit tests written against a reading of Boulder. Now the harness publishes the record the way a zone owner would, once, before the first order, taking it verbatim from what the gateway reports; Pebble looks it up and parses it as an RFC 8659 issue-value. A record this implementation renders and a CA implementation rejects now fails the suite. That is possible because the mock answers TCP, and worth doing because the gateway itself never writes the record -- which is the claim the last assertion checks, scoped to the `_acme-challenge` name certbot would have written rather than to the zone being empty. The mock is built from the repo instead of pulled as kvin/mock-cf-dns-api, which was HTTP-only and could not have served this. Both suites now build the same image.
1 parent 103c6c4 commit de91780

2 files changed

Lines changed: 60 additions & 17 deletions

File tree

dstack/gateway/test-run/e2e/docker-compose.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ services:
3333

3434
# ==================== Mock Services ====================
3535

36-
# Mock Cloudflare DNS API
36+
# Mock Cloudflare DNS API. Built from the repo rather than pulled, and the
37+
# same image the full-stack suite builds, so both suites answer DNS the same
38+
# way and neither depends on a prebuilt image nobody can rebuild.
3739
mock-cf-dns-api:
38-
image: kvin/mock-cf-dns-api:latest
40+
build:
41+
context: ../../../../test-suites/full-stack-compose/mock-cf-dns
42+
image: dstack-e2e-mock-cf-dns:local
3943
container_name: mock-cf-dns-api
4044
networks:
4145
certbot-test:
@@ -45,6 +49,8 @@ services:
4549
environment:
4650
- PORT=8080
4751
- DEBUG=true
52+
# The zones certbot writes into and Pebble reads back out of.
53+
- MOCK_CF_ZONES=test0.local,test1.local,test2.local,persist0.local
4854
healthcheck:
4955
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
5056
interval: 5s
@@ -66,7 +72,9 @@ services:
6672
- "15000:15000" # Management interface
6773
environment:
6874
- PEBBLE_VA_NOSLEEP=1
69-
- PEBBLE_VA_ALWAYS_VALID=1 # Skip actual DNS validation for testing
75+
# No PEBBLE_VA_ALWAYS_VALID: the mock answers DNS over TCP, which is the
76+
# only transport Pebble uses once it is given -dnsserver, so challenges
77+
# are validated against the records rather than waved through.
7078
healthcheck:
7179
test: ["CMD", "wget", "-q", "--spider", "http://localhost:14000/dir"]
7280
interval: 5s

dstack/gateway/test-run/e2e/test.sh

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ CERT_DOMAINS="test0.local test1.local test2.local"
3838
# here the certificate is checked through the admin API instead.
3939
PERSIST_DOMAIN="persist0.local"
4040

41+
# Mock zone ID, as server.py derives it from the zone name.
42+
PERSIST_ZONE_ID="zone-persist0-local"
43+
4144
# Pebble answers to this name, not to letsencrypt.org, and a validation record
4245
# naming a CA the challenge does not list is ignored.
4346
PERSIST_ISSUER="pebble.letsencrypt.org"
@@ -174,15 +177,43 @@ test_persist_challenge_survives_an_edit() {
174177
echo "$info" | grep -q "dns-persist-01"
175178
}
176179

177-
# The point of the whole exercise: a certificate is issued for a domain the
178-
# gateway holds no credential for. The record is not published anywhere in this
179-
# harness, so the pre-order self-check cannot pass -- it is advisory by design,
180-
# and certbot warns and proceeds because the CA's DNS view is not this node's.
181-
# Pebble runs with PEBBLE_VA_ALWAYS_VALID, so it accepts.
180+
# Publish the validation record the way a zone owner would: once, by hand,
181+
# before the first order. certbot never writes it -- that is the whole point --
182+
# so the harness stands in for the operator here.
182183
#
183-
# That leaves the challenge itself covered end to end -- selected out of the
184-
# authorization, posted ready, finalized, fetched -- and the DNS answer the only
185-
# part standing in.
184+
# The record has to be the one the gateway reports, byte for byte, because that
185+
# is what Pebble parses as an RFC 8659 issue-value and matches against its own
186+
# issuer name and the requesting account.
187+
publish_persist_record() {
188+
local info rdata
189+
info=$(admin_post GetZtDomain '{"domain": "'"${PERSIST_DOMAIN}"'"}') || return 1
190+
# "_validation-persist.<domain>. IN TXT \"<rdata>\"" -> <rdata>
191+
rdata=$(echo "$info" \
192+
| tr ',' '\n' \
193+
| grep -F "_validation-persist.${PERSIST_DOMAIN}. IN TXT" \
194+
| sed -e 's/.*IN TXT \\"//' -e 's/\\".*//')
195+
if [ -z "$rdata" ]; then
196+
log_error "no validation record reported for ${PERSIST_DOMAIN}" >&2
197+
return 1
198+
fi
199+
# stderr, not stdout: run_test captures the function's stdout and compares
200+
# the whole of it against "0".
201+
log_info "Publishing: _validation-persist.${PERSIST_DOMAIN} TXT ${rdata}" >&2
202+
curl -sf -X POST "${MOCK_CF_API}/client/v4/zones/${PERSIST_ZONE_ID}/dns_records" \
203+
-H "Authorization: Bearer ${CF_API_TOKEN}" \
204+
-H "Content-Type: application/json" \
205+
-d '{
206+
"type": "TXT",
207+
"name": "_validation-persist.'"${PERSIST_DOMAIN}"'",
208+
"content": "'"${rdata}"'",
209+
"ttl": 1
210+
}' > /dev/null
211+
}
212+
213+
# The point of the whole exercise: a certificate is issued for a domain the
214+
# gateway holds no credential for, and the CA validates the published record
215+
# rather than being told to skip it -- Pebble runs without
216+
# PEBBLE_VA_ALWAYS_VALID here.
186217
test_persist_domain_issues_a_certificate() {
187218
admin_post RenewZtDomainCert \
188219
'{"domain": "'"${PERSIST_DOMAIN}"'", "force": true}' > /dev/null 2>&1 || true
@@ -202,11 +233,13 @@ test_persist_domain_issues_a_certificate() {
202233
}
203234

204235
# The claim the whole challenge exists for: certbot reads DNS and never writes
205-
# it, so nothing for this domain may ever reach the provider API.
236+
# it. Scoped to the record certbot would have written -- the zone is not empty,
237+
# because the harness published the validation record above standing in for the
238+
# operator, and that one is the point rather than a violation.
206239
test_persist_domain_never_touches_the_provider() {
207240
local records
208241
records=$(curl -sf "${MOCK_CF_API}/api/records" 2>/dev/null || echo "[]")
209-
! echo "$records" | grep -q "${PERSIST_DOMAIN}"
242+
! echo "$records" | grep -qF "_acme-challenge.${PERSIST_DOMAIN}"
210243
}
211244

212245
# ==================== Test Functions ====================
@@ -447,12 +480,14 @@ main() {
447480
"$(test_persist_domain_needs_no_credential; echo $?)"
448481
run_test "Challenge survives an edit that omits it" \
449482
"$(test_persist_challenge_survives_an_edit; echo $?)"
450-
run_test "Certificate issues without a DNS credential" \
451-
"$(test_persist_domain_issues_a_certificate; echo $?)"
452-
# After issuance: every record names the ACME account, so there is nothing
453-
# to report until one exists, and issuing is what creates it here.
483+
# The record names the ACME account, so it can only be rendered -- and
484+
# published -- once one exists. Registering is what the first order does.
454485
run_test "Required validation record is reported" \
455486
"$(test_persist_domain_reports_its_record; echo $?)"
487+
run_test "Validation record publishes" \
488+
"$(publish_persist_record; echo $?)"
489+
run_test "Certificate issues without a DNS credential" \
490+
"$(test_persist_domain_issues_a_certificate; echo $?)"
456491
run_test "No DNS record is ever written for it" \
457492
"$(test_persist_domain_never_touches_the_provider; echo $?)"
458493

0 commit comments

Comments
 (0)