fix(certbot): upgrade instant-acme so an unknown challenge type does not break issuance - #1129
Conversation
Live run against Let's Encrypt stagingDeployed a gateway built from The parse failure is gone, and issuance now runs deep into the flow: Reaching that line means the authorization was fetched and deserialized, the The record is real, and its value matches what certbot computed: A certificate was not obtained, for a reason unrelated to this change, which I want to state plainly rather than leave implied by a green result.
The self-check budget is So on any zone whose SOA minimum exceeds CI: all 17 checks pass. |
|
Added The lockfile in the original push still pinned CI did not catch this because the steps that build certbot do not pass
|
A certificate was issuedFollowing up on the earlier comment, which reported issuance getting as far as publishing the TXT record but timing out on the self-check. Once the recursive resolver's cached negative expired, the same gateway completed the flow: The certificate is real, from Let's Encrypt staging, and is being served by the proxy: And traffic reaches the application inside the CVM through it: That closes the loop this PR opened: order creation, authorization parsing, challenge selection, key authorization, DNS publication, challenge validation, finalization and certificate retrieval all work against a real ACME server. On 0.7.2 none of it did — issuance failed at the first step. The self-check delay that produced the earlier timeout is a separate defect, fixed in #1130. |
Problem
Certificate issuance against Let's Encrypt fails outright. A gateway asking staging for
*.06rc0.kvin.wangnever gets a certificate:Let's Encrypt now offers two DNS challenges on the same authorization:
dns-persist-01carries notoken.instant-acme0.7.2 declaresChallenge.tokenas a plainStringwith no default (instant-acme-0.7.2/src/types.rs:262), so that entry fails to deserialize — and becausechallengesis a single array, the failure takes the whole authorization down with it, including the perfectly usabledns-01challenge sitting next to it.The challenge type is not the problem: 0.7.2 already models unknown types as
ChallengeType::Unknown(String). It is the requiredtokenfield on a sibling object that breaks the parse, which is why the error ismissing fieldrather thanunknown variant.Nothing in certbot's own code is wrong, and no configuration avoids this: the failure happens inside the library while reading the authorization, before certbot ever picks a challenge.
This is not limited to staging. Once
dns-persist-01reaches production, every gateway's issuance and renewal fails the same way, so deployments would start losing certificates as they expire rather than failing loudly on day one.Fix
Upgrade
instant-acme0.7.2 → 0.8.5, which fixes exactly this upstream:certbot's challenge selection is unchanged — it still does
find(|c| c.r#type == ChallengeType::Dns01)— it just no longer loses the array before getting there.The 0.8 API required matching changes, all mechanical:
Account::from_credentials_and_http/create_with_http→Account::builder_with_http(..).from_credentials(..)/.create(..).NewOrderfields are private →NewOrder::new(&identifiers).Order::authorizations()returns a stream instead of aVec, soauthorize()andfind_error()iterate withwhile let Some(..) = .next().await.Order::set_challenge_ready(url)is gone; readiness now goes throughChallengeHandle::set_ready(). The handle borrows the order and cannot be held across the DNS-propagation wait, so setting challenges ready became a second pass over the authorizations (set_challenges_ready). The ordering is unchanged: publish every TXT record, verify propagation, then tell the server. Because the handle no longer exposes the challenge URL, theurlfield was dropped from certbot's ownChallengerecord, which only used it for this call and a log line.Order::finalize(csr)→finalize_csr(csr);Problemgainedsubproblems.HttpClient::requestnow takesRequest<BodyWrapper<Bytes>>.BodyWrapper<Bytes>implementshttp_body::Body, so the existing.collect()works unchanged.One subtlety worth calling out: the identifier now arrives as
AuthorizedIdentifier, whoseDisplayrenders a wildcard authorization as*.example.com. Formatting that into_acme-challenge.{}would publish the record at_acme-challenge.*.example.com. The code takes the bareidentifierfield instead, preserving 0.7's behaviour.Verification
The bug, isolated. Same authorization JSON, same library, two versions:
instant-acmeFAILED: missing field 'token' at line 5 column 83PARSED ok, 2 challengesThe 0.7.2 message is character-for-character the one from the gateway log above, which is what ties this minimal case to the real failure rather than to something merely similar.
Regression test (
challenge_parsing_tests, shaped from a real acme-staging-v02 response) asserts that an authorization containingdns-persist-01parses, that both challenges survive, thatdns-01keeps its token, that the tokenless challenge yields an empty token rather than an error, and that a wildcard authorization still reports the bare name.Note on its scope: this test cannot be run against 0.7.2 as a counterfactual —
AuthorizationStatedid not exist there (it wasAuthorization), so the crate does not compile at all on the old version. The counterfactual above is what proves the failure; the test exists to stop a future downgrade from silently reintroducing it.Build/lint:
cargo check --workspace,cargo test -p certbot,cargo fmt --check, andcargo clippy -p certbot -p dstack-gateway -- -D warningsall pass.Not yet covered: a live issuance against Let's Encrypt staging through a deployed gateway. The image build for that was still running when this was opened; I'll add the result as a comment. Everything above is offline evidence.