acme_proxy/signer/relay/http01.rs
1//! Serving the challenge file the *upstream* CA asks for.
2//!
3//! ## Why this exists at all
4//!
5//! The deliberate twin of [`super::dns01`], and it exists for the same
6//! asymmetry. When the upstream is a real CA it issues its own challenge, and
7//! the key authorization it expects is computed from **this proxy's** account
8//! thumbprint at that upstream — not the end client's. The two are different
9//! accounts on different servers, so the original client cannot answer it even
10//! in principle: only this server knows the right value.
11//!
12//! Where `dns01` answers that by writing a TXT record, this module answers it
13//! by holding the key authorization in memory for the seconds the CA needs it,
14//! and letting a route on the server's own root router serve it.
15//!
16//! ## Why a route, and not a listener or a webroot
17//!
18//! RFC 8555 §8.3 fixes the *path* the CA fetches, not the port and not the
19//! host it ultimately reaches: §8.3 explicitly permits following redirects,
20//! and every real CA does. So the responder does not need to be the thing
21//! listening on port 80 at the identifier — it only needs the operator's
22//! existing web server to forward or redirect `/.well-known/acme-challenge/`
23//! here. That is one `proxy_pass` or one `return 301`.
24//!
25//! Given that, a second listener bound to port 80 would buy nothing but a
26//! privileged bind and a socket to reason about, and a webroot provider would
27//! buy nothing but a shared filesystem to arrange. Neither is offered. What
28//! *is* required — the forwarder — is stated at startup by [`super`]'s
29//! `from_config`, because it is out of this process's reach and would
30//! otherwise be discovered at the first failed issuance.
31//!
32//! ## The file's content is not defined here
33//!
34//! [`crate::challenge::http_01`] owns the well-known path, and
35//! [`super::flow`] builds the key authorization from the account thumbprint.
36//! This module only stores bytes under a token — the same separation
37//! [`super::dns01`] keeps by calling into [`crate::challenge::dns_01`] rather
38//! than restating the record convention.
39
40use std::collections::HashMap;
41use std::sync::{Arc, PoisonError, RwLock};
42
43/// Holds the key authorizations the responder route serves.
44///
45/// A trait rather than a concrete type for the same two reasons
46/// [`super::dns01::DnsUpdater`] is one: the orchestration in [`super::flow`]
47/// can be driven against a stub, and a future provider slots in without
48/// touching the relay.
49pub trait TokenStore: Send + Sync {
50 /// Makes `key_authorization` fetchable at
51 /// `/.well-known/acme-challenge/{token}`.
52 ///
53 /// Additive: several authorizations of one order are answered in sequence
54 /// but a multi-perspective CA may still have a fetch in flight for a token
55 /// published earlier, so entries never displace one another.
56 fn publish(&self, token: &str, key_authorization: &str);
57
58 /// Drops a previously published entry. Idempotent — [`PublishedToken`]'s
59 /// `Drop` may run on a path that already retracted.
60 fn retract(&self, token: &str);
61
62 /// What the responder route serves.
63 ///
64 /// On the trait rather than only on [`MemoryTokenStore`] because
65 /// `build_app` reaches the store through `Arc<dyn TokenStore>` — via
66 /// [`SignerBackend::http01_tokens`] — and cannot downcast past it.
67 ///
68 /// [`SignerBackend::http01_tokens`]: crate::signer::SignerBackend::http01_tokens
69 fn lookup(&self, token: &str) -> Option<String>;
70}
71
72/// The process-local store the `http01` strategy publishes into.
73///
74/// A `std::sync::RwLock`, not a `tokio::sync` one: unlike
75/// [`local_ca`](crate::signer::local_ca)'s revocation ledger — which uses an
76/// async mutex precisely because it awaits two file writes inside its critical
77/// section — every section here is one hash-map operation and awaits nothing.
78///
79/// Poisoning is recovered from rather than propagated. A panic anywhere else
80/// holding this lock must not turn the responder into a permanent 404 for
81/// every certificate this server will ever relay; the worst a torn write could
82/// leave behind is a stale token, which the CA would simply fail to match.
83#[derive(Debug, Default)]
84pub struct MemoryTokenStore {
85 entries: RwLock<HashMap<String, String>>,
86}
87
88impl MemoryTokenStore {
89 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93}
94
95impl TokenStore for MemoryTokenStore {
96 fn publish(&self, token: &str, key_authorization: &str) {
97 self.entries
98 .write()
99 .unwrap_or_else(PoisonError::into_inner)
100 .insert(token.to_string(), key_authorization.to_string());
101 }
102
103 fn retract(&self, token: &str) {
104 self.entries
105 .write()
106 .unwrap_or_else(PoisonError::into_inner)
107 .remove(token);
108 }
109
110 fn lookup(&self, token: &str) -> Option<String> {
111 self.entries
112 .read()
113 .unwrap_or_else(PoisonError::into_inner)
114 .get(token)
115 .cloned()
116 }
117}
118
119/// A published token that retracts itself when dropped.
120///
121/// [`super::flow`]'s `answer_dns01` retracts explicitly, in a `let triggered =
122/// …; delete; triggered?` dance, because its retraction is a network round
123/// trip and cannot run from `Drop`. This one can, and that closes a hole the
124/// dns-01 side structurally cannot: `spawn_relay` wraps the whole relay in a
125/// `tokio::time::timeout`, so a slow upstream **drops the future mid-poll** and
126/// an explicit retract would simply never run — leaking one live key
127/// authorization per timed-out relay, for the life of the process.
128pub struct PublishedToken {
129 store: Arc<dyn TokenStore>,
130 token: String,
131}
132
133impl PublishedToken {
134 /// Publishes `key_authorization` and returns the guard that retracts it.
135 pub fn publish(store: Arc<dyn TokenStore>, token: &str, key_authorization: &str) -> Self {
136 store.publish(token, key_authorization);
137 Self {
138 store,
139 token: token.to_string(),
140 }
141 }
142}
143
144impl Drop for PublishedToken {
145 fn drop(&mut self) {
146 self.store.retract(&self.token);
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 /// The production store's whole contract, which the relay tests reach only
155 /// through a stub.
156 #[test]
157 fn the_memory_token_store_round_trips() {
158 let store = MemoryTokenStore::new();
159 assert_eq!(store.lookup("absent"), None);
160
161 store.publish("tok", "tok.thumbprint");
162 assert_eq!(store.lookup("tok").as_deref(), Some("tok.thumbprint"));
163
164 // Two authorizations of one order live side by side.
165 store.publish("other", "other.thumbprint");
166 assert_eq!(store.lookup("tok").as_deref(), Some("tok.thumbprint"));
167
168 store.retract("tok");
169 assert_eq!(store.lookup("tok"), None);
170 assert_eq!(store.lookup("other").as_deref(), Some("other.thumbprint"));
171
172 // Idempotent: `PublishedToken`'s `Drop` may run after an explicit one.
173 store.retract("tok");
174 }
175
176 /// The guard is the only thing standing between a cancelled relay and a
177 /// key authorization that stays fetchable forever.
178 #[test]
179 fn a_published_token_retracts_itself_when_dropped() {
180 let store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
181 {
182 let _guard = PublishedToken::publish(store.clone(), "tok", "tok.thumbprint");
183 assert_eq!(store.lookup("tok").as_deref(), Some("tok.thumbprint"));
184 }
185 assert_eq!(store.lookup("tok"), None);
186 }
187}