Skip to content

Commit c23276f

Browse files
committed
Add ACME HTTP-01 failure hints
1 parent 6518593 commit c23276f

4 files changed

Lines changed: 84 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ behavior when the change improves security or project direction.
99

1010
## Unreleased
1111

12+
### Added
13+
14+
- Route-scoped cache runtime stats now appear in the protected admin cache
15+
status endpoint and activity-reset response.
16+
- ACME HTTP-01 client failures now include published challenge URLs after
17+
challenge material has been written, making failed authorization checks easier
18+
to debug from production logs.
19+
1220
## 1.1.0 - TLS Policy And Certificate Operations
1321

1422
Released: pending

ROADMAP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,10 @@ These are realistic additions to implement across the stable core and early
234234
- Improve ACME command output for production runs: report per-target
235235
`skipped`, `renewed`, and `failed` status, include domain/order context
236236
on issuer failures, and print the HTTP-01 challenge path/URL when an
237-
authorization fails.
237+
authorization fails. Implemented for target statuses, per-target
238+
success/failure lines, due-only status messaging, and HTTP-01 URL context
239+
after challenge files are published. Planned: richer issuer
240+
authorization/order context when the client library exposes it cleanly.
238241
- Add native systemd deployment support before `1.0.0`: a hardened
239242
`fluxheim.service`, optional environment file, tmpfiles/sysusers
240243
guidance, documented install paths, config validation before start, and

docs/certificate-renewal.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,10 @@ with `acme attempted: 0` and a status message saying no certificates are due.
163163
First issuance normally does not need `--force-renew`: missing certificate files
164164
are due targets. The command prints every target with `status=due`,
165165
`status=skipped`, or `status=forced`, then reports per-target `renewed:` and
166-
`failed:` lines. It exits non-zero if any target failed, while still reporting
167-
successful renewals from the same run.
166+
`failed:` lines. For HTTP-01 failures after challenge files are published, the
167+
failure includes `published_http_01=` URLs so operators can test the exact public
168+
challenge paths that the issuer should have reached. It exits non-zero if any
169+
target failed, while still reporting successful renewals from the same run.
168170

169171
Use `--force-renew` only for deliberate emergency rotation or testing when you
170172
really need to reissue certificates that are still valid; repeated forced

src/acme.rs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,11 @@ pub fn execute_renewal<Client: AcmeIssuerClient>(
531531
.finalize_http_01_order(&order, &challenge_store)
532532
.map_err(|error| AcmeRenewalError::Client {
533533
issuer: item.target.issuer.clone(),
534-
message: error.to_string(),
534+
message: acme_client_error_message_with_http_01_context(
535+
error,
536+
&item.target.domains,
537+
&published,
538+
),
535539
})?;
536540
let certificate = install_managed_certificate(
537541
storage,
@@ -713,7 +717,11 @@ async fn execute_instant_http_01_renewal(
713717
.await
714718
.map_err(|error| AcmeRenewalError::Client {
715719
issuer: item.target.issuer.clone(),
716-
message: error.to_string(),
720+
message: acme_client_error_message_with_http_01_context(
721+
error,
722+
&item.target.domains,
723+
&published,
724+
),
717725
})?;
718726
}
719727
}
@@ -724,15 +732,23 @@ async fn execute_instant_http_01_renewal(
724732
.await
725733
.map_err(|error| AcmeRenewalError::Client {
726734
issuer: item.target.issuer.clone(),
727-
message: error.to_string(),
735+
message: acme_client_error_message_with_http_01_context(
736+
error,
737+
&item.target.domains,
738+
&published,
739+
),
728740
})?;
729741
let private_key_pem = Zeroizing::new(
730742
order
731743
.finalize()
732744
.await
733745
.map_err(|error| AcmeRenewalError::Client {
734746
issuer: item.target.issuer.clone(),
735-
message: error.to_string(),
747+
message: acme_client_error_message_with_http_01_context(
748+
error,
749+
&item.target.domains,
750+
&published,
751+
),
736752
})?
737753
.into_bytes(),
738754
);
@@ -741,7 +757,11 @@ async fn execute_instant_http_01_renewal(
741757
.await
742758
.map_err(|error| AcmeRenewalError::Client {
743759
issuer: item.target.issuer.clone(),
744-
message: error.to_string(),
760+
message: acme_client_error_message_with_http_01_context(
761+
error,
762+
&item.target.domains,
763+
&published,
764+
),
745765
})?
746766
.into_bytes();
747767
let certificate = install_managed_certificate(
@@ -1290,6 +1310,31 @@ fn cleanup_http_01_challenges(
12901310
Ok(())
12911311
}
12921312

1313+
fn acme_client_error_message_with_http_01_context(
1314+
error: impl fmt::Display,
1315+
domains: &[String],
1316+
tokens: &[String],
1317+
) -> String {
1318+
let message = error.to_string();
1319+
let urls = http_01_challenge_urls(domains, tokens);
1320+
if urls.is_empty() {
1321+
return message;
1322+
}
1323+
format!("{message}; published_http_01={}", urls.join(","))
1324+
}
1325+
1326+
fn http_01_challenge_urls(domains: &[String], tokens: &[String]) -> Vec<String> {
1327+
let mut urls = Vec::new();
1328+
for domain in domains {
1329+
for token in tokens {
1330+
urls.push(format!(
1331+
"http://{domain}/.well-known/acme-challenge/{token}"
1332+
));
1333+
}
1334+
}
1335+
urls
1336+
}
1337+
12931338
#[cfg(feature = "acme-client")]
12941339
fn cleanup_tls_alpn_01_challenges(
12951340
store: &AcmeTlsAlpn01ChallengeStore,
@@ -3116,6 +3161,9 @@ mod tests {
31163161
let error = execute_renewal(&config, &item, &mut client).unwrap_err();
31173162

31183163
assert!(matches!(error, AcmeRenewalError::Client { .. }));
3164+
assert!(error.to_string().contains(
3165+
"published_http_01=http://example.test/.well-known/acme-challenge/token_123"
3166+
));
31193167
assert_eq!(client.finalize_calls, 1);
31203168
let store = AcmeHttp01ChallengeStore::new(&storage, "example");
31213169
assert_eq!(store.load_key_authorization("token_123").unwrap(), None);
@@ -3124,6 +3172,21 @@ mod tests {
31243172
assert!(!paths.key_path.exists());
31253173
}
31263174

3175+
#[test]
3176+
fn http_01_error_context_lists_all_published_urls() {
3177+
let message = super::acme_client_error_message_with_http_01_context(
3178+
"authorization failed",
3179+
&["example.test".to_owned(), "www.example.test".to_owned()],
3180+
&["token-a".to_owned(), "token-b".to_owned()],
3181+
);
3182+
3183+
assert!(message.starts_with("authorization failed; published_http_01="));
3184+
assert!(message.contains("http://example.test/.well-known/acme-challenge/token-a"));
3185+
assert!(message.contains("http://example.test/.well-known/acme-challenge/token-b"));
3186+
assert!(message.contains("http://www.example.test/.well-known/acme-challenge/token-a"));
3187+
assert!(message.contains("http://www.example.test/.well-known/acme-challenge/token-b"));
3188+
}
3189+
31273190
#[test]
31283191
fn eab_file_secrets_are_trimmed_bounded_and_redacted() {
31293192
let root = crate::test_support::unique_temp_path("acme-eab-file-secrets");

0 commit comments

Comments
 (0)