Skip to main content

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}