sentinel_proxy/acme/
challenge.rs

1//! HTTP-01 ACME challenge management
2//!
3//! Manages pending ACME HTTP-01 challenges for serving via
4//! `/.well-known/acme-challenge/<token>`.
5
6use dashmap::DashMap;
7use std::sync::Arc;
8use tracing::{debug, trace};
9
10/// HTTP-01 challenge path prefix
11pub const ACME_CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
12
13/// Manages pending ACME HTTP-01 challenges
14///
15/// When the ACME server needs to validate domain ownership, it requests
16/// a specific URL path. This manager stores the token -> key authorization
17/// mapping so the proxy can serve the correct response.
18///
19/// # Thread Safety
20///
21/// Uses `DashMap` for lock-free concurrent access from multiple request
22/// handling threads.
23#[derive(Debug)]
24pub struct ChallengeManager {
25    /// Map of challenge token -> key authorization response
26    challenges: Arc<DashMap<String, String>>,
27}
28
29impl ChallengeManager {
30    /// Create a new challenge manager
31    pub fn new() -> Self {
32        Self {
33            challenges: Arc::new(DashMap::new()),
34        }
35    }
36
37    /// Register a pending challenge
38    ///
39    /// Called when starting the ACME challenge flow. The key authorization
40    /// will be served when the ACME server requests the challenge URL.
41    ///
42    /// # Arguments
43    ///
44    /// * `token` - The challenge token from the ACME server
45    /// * `key_authorization` - The response to return (token + account key thumbprint)
46    pub fn add_challenge(&self, token: &str, key_authorization: &str) {
47        debug!(token = %token, "Registering ACME HTTP-01 challenge");
48        self.challenges
49            .insert(token.to_string(), key_authorization.to_string());
50    }
51
52    /// Remove a completed or expired challenge
53    ///
54    /// Called after the challenge is validated or times out.
55    pub fn remove_challenge(&self, token: &str) {
56        if self.challenges.remove(token).is_some() {
57            debug!(token = %token, "Removed ACME challenge");
58        }
59    }
60
61    /// Get the key authorization response for a challenge token
62    ///
63    /// Returns `Some(key_authorization)` if the token is registered,
64    /// `None` otherwise.
65    pub fn get_response(&self, token: &str) -> Option<String> {
66        let result = self.challenges.get(token).map(|v| v.clone());
67        if result.is_some() {
68            trace!(token = %token, "ACME challenge token found");
69        } else {
70            trace!(token = %token, "ACME challenge token not found");
71        }
72        result
73    }
74
75    /// Check if this is an ACME challenge request path
76    ///
77    /// Returns `Some(token)` if the path matches the challenge prefix,
78    /// `None` otherwise.
79    pub fn extract_token(path: &str) -> Option<&str> {
80        path.strip_prefix(ACME_CHALLENGE_PREFIX)
81    }
82
83    /// Get the number of pending challenges
84    pub fn pending_count(&self) -> usize {
85        self.challenges.len()
86    }
87
88    /// Clear all pending challenges
89    ///
90    /// Called during shutdown or reset.
91    pub fn clear(&self) {
92        let count = self.challenges.len();
93        self.challenges.clear();
94        if count > 0 {
95            debug!(cleared = count, "Cleared all pending ACME challenges");
96        }
97    }
98}
99
100impl Default for ChallengeManager {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Clone for ChallengeManager {
107    fn clone(&self) -> Self {
108        Self {
109            challenges: Arc::clone(&self.challenges),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_add_and_get_challenge() {
120        let manager = ChallengeManager::new();
121
122        manager.add_challenge("test-token", "test-key-auth");
123
124        let response = manager.get_response("test-token");
125        assert_eq!(response, Some("test-key-auth".to_string()));
126    }
127
128    #[test]
129    fn test_get_nonexistent_challenge() {
130        let manager = ChallengeManager::new();
131
132        let response = manager.get_response("nonexistent");
133        assert_eq!(response, None);
134    }
135
136    #[test]
137    fn test_remove_challenge() {
138        let manager = ChallengeManager::new();
139
140        manager.add_challenge("test-token", "test-key-auth");
141        assert_eq!(manager.pending_count(), 1);
142
143        manager.remove_challenge("test-token");
144        assert_eq!(manager.pending_count(), 0);
145
146        let response = manager.get_response("test-token");
147        assert_eq!(response, None);
148    }
149
150    #[test]
151    fn test_extract_token() {
152        assert_eq!(
153            ChallengeManager::extract_token("/.well-known/acme-challenge/abc123"),
154            Some("abc123")
155        );
156
157        assert_eq!(
158            ChallengeManager::extract_token("/.well-known/acme-challenge/"),
159            Some("")
160        );
161
162        assert_eq!(ChallengeManager::extract_token("/other/path"), None);
163
164        assert_eq!(
165            ChallengeManager::extract_token("/.well-known/acme-challenge"),
166            None
167        );
168    }
169
170    #[test]
171    fn test_clear_challenges() {
172        let manager = ChallengeManager::new();
173
174        manager.add_challenge("token1", "auth1");
175        manager.add_challenge("token2", "auth2");
176        assert_eq!(manager.pending_count(), 2);
177
178        manager.clear();
179        assert_eq!(manager.pending_count(), 0);
180    }
181
182    #[test]
183    fn test_clone_shares_state() {
184        let manager1 = ChallengeManager::new();
185        let manager2 = manager1.clone();
186
187        manager1.add_challenge("token", "auth");
188
189        // Clone should see the same challenge
190        assert_eq!(manager2.get_response("token"), Some("auth".to_string()));
191    }
192}