1use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use redis::AsyncCommands;
15use serde::{Deserialize, Serialize};
16
17use super::CertStorage;
18use crate::acme::{AcmeError, CertId, StoredCert};
19use crate::plugins::resources::PluginResources;
20use crate::plugins::util::cookie_session::CookieSealer;
21use crate::stores::redis_store::RedisStoreClient;
22
23const SEAL_TTL: Duration = Duration::from_secs(100 * 365 * 86_400);
25
26const RENEW_LEASE_SCRIPT: &str = r#"
27if redis.call('GET', KEYS[1]) == ARGV[1] then
28 return redis.call('PEXPIRE', KEYS[1], ARGV[2])
29else
30 return 0
31end"#;
32
33const RELEASE_LEASE_SCRIPT: &str = r#"
34if redis.call('GET', KEYS[1]) == ARGV[1] then
35 return redis.call('DEL', KEYS[1])
36else
37 return 0
38end"#;
39
40pub(crate) fn account_key(prefix: &str) -> String {
41 format!("{prefix}:{}:account", crate::stores::namespaces::ACME)
42}
43pub(super) fn cert_key(prefix: &str, id: &CertId) -> String {
44 format!(
45 "{prefix}:{}:cert:{{{}}}",
46 crate::stores::namespaces::ACME,
47 id.as_str()
48 )
49}
50fn challenge_key(prefix: &str, domain: &str) -> String {
51 format!(
52 "{prefix}:{}:challenge:{domain}",
53 crate::stores::namespaces::ACME
54 )
55}
56fn lease_key(prefix: &str, id: &CertId) -> String {
57 format!(
58 "{prefix}:{}:lease:{{{}}}",
59 crate::stores::namespaces::ACME,
60 id.as_str()
61 )
62}
63
64#[derive(Serialize, Deserialize)]
65struct CertRecord {
66 chain_pem: String,
67 key_sealed: String,
69 issued_at: i64,
70}
71
72pub struct RedisCertStorage {
78 resources: Arc<PluginResources>,
79 name: String,
80 sealer: CookieSealer,
81 renew_script: redis::Script,
82 release_script: redis::Script,
83}
84
85impl RedisCertStorage {
86 pub fn new(
87 resources: Arc<PluginResources>,
88 name: impl Into<String>,
89 encryption_key: &str,
90 ) -> Self {
91 Self {
92 resources,
93 name: name.into(),
94 sealer: CookieSealer::new(encryption_key),
95 renew_script: redis::Script::new(RENEW_LEASE_SCRIPT),
96 release_script: redis::Script::new(RELEASE_LEASE_SCRIPT),
97 }
98 }
99
100 pub(crate) fn client(&self) -> Result<Arc<RedisStoreClient>, AcmeError> {
103 self.resources
104 .stores
105 .load()
106 .client(&self.name)
107 .map_err(AcmeError::Storage)
108 }
109
110 async fn conn(
114 &self,
115 ) -> Result<(Arc<RedisStoreClient>, crate::stores::redis_store::StoreConn), AcmeError> {
116 let client = self.client()?;
117 let conn = client.conn().await.map_err(AcmeError::Storage)?;
118 Ok((client, conn))
119 }
120
121 fn err(&self, what: &str, e: impl std::fmt::Display) -> AcmeError {
122 AcmeError::Storage(format!("store '{}': {what}: {e}", self.name))
123 }
124
125 fn open(&self, sealed: &str, what: &str) -> Result<Vec<u8>, AcmeError> {
126 self.sealer.open(sealed).map_err(|e| {
127 AcmeError::Storage(format!(
128 "store '{}': cannot unseal {what} ({e}) — was acme.storage.encryption_key changed?",
129 self.name
130 ))
131 })
132 }
133}
134
135#[async_trait]
136impl CertStorage for RedisCertStorage {
137 fn label(&self) -> String {
138 format!("store:{}", self.name)
139 }
140
141 async fn load_account(&self) -> Result<Option<Vec<u8>>, AcmeError> {
142 let (client, mut conn) = self.conn().await?;
143 let sealed: Option<String> = conn
144 .get(account_key(client.key_prefix()))
145 .await
146 .map_err(|e| self.err("load_account", e))?;
147 sealed
148 .map(|s| self.open(&s, "account credentials"))
149 .transpose()
150 }
151
152 async fn save_account(&self, creds: &[u8]) -> Result<(), AcmeError> {
153 let (client, mut conn) = self.conn().await?;
154 conn.set::<_, _, ()>(
155 account_key(client.key_prefix()),
156 self.sealer.seal(creds, SEAL_TTL),
157 )
158 .await
159 .map_err(|e| self.err("save_account", e))
160 }
161
162 async fn load_cert(&self, id: &CertId) -> Result<Option<StoredCert>, AcmeError> {
163 let (client, mut conn) = self.conn().await?;
164 let raw: Option<String> = conn
165 .get(cert_key(client.key_prefix(), id))
166 .await
167 .map_err(|e| self.err("load_cert", e))?;
168 let Some(raw) = raw else { return Ok(None) };
169 let rec: CertRecord =
170 serde_json::from_str(&raw).map_err(|e| self.err("load_cert: corrupt record", e))?;
171 let key = self.open(&rec.key_sealed, "certificate private key")?;
172 Ok(Some(StoredCert {
173 chain_pem: rec.chain_pem,
174 key_pem: String::from_utf8(key).map_err(|e| self.err("load_cert: key utf8", e))?,
175 issued_at: rec.issued_at,
176 }))
177 }
178
179 async fn save_cert(&self, id: &CertId, cert: &StoredCert) -> Result<(), AcmeError> {
180 let rec = CertRecord {
181 chain_pem: cert.chain_pem.clone(),
182 key_sealed: self.sealer.seal(cert.key_pem.as_bytes(), SEAL_TTL),
183 issued_at: cert.issued_at,
184 };
185 let (client, mut conn) = self.conn().await?;
186 conn.set::<_, _, ()>(
187 cert_key(client.key_prefix(), id),
188 serde_json::to_string(&rec).unwrap(),
189 )
190 .await
191 .map_err(|e| self.err("save_cert", e))
192 }
193
194 async fn put_challenge(
195 &self,
196 domain: &str,
197 key_auth: &str,
198 ttl: Duration,
199 ) -> Result<(), AcmeError> {
200 let (client, mut conn) = self.conn().await?;
201 redis::cmd("SET")
202 .arg(challenge_key(client.key_prefix(), domain))
203 .arg(key_auth)
204 .arg("PX")
205 .arg(ttl.as_millis().max(1) as u64)
206 .query_async::<()>(&mut conn)
207 .await
208 .map_err(|e| self.err("put_challenge", e))
209 }
210
211 async fn get_challenge(&self, domain: &str) -> Result<Option<String>, AcmeError> {
212 let (client, mut conn) = self.conn().await?;
213 conn.get(challenge_key(client.key_prefix(), domain))
214 .await
215 .map_err(|e| self.err("get_challenge", e))
216 }
217
218 async fn remove_challenge(&self, domain: &str) -> Result<(), AcmeError> {
219 let (client, mut conn) = self.conn().await?;
220 conn.del::<_, ()>(challenge_key(client.key_prefix(), domain))
221 .await
222 .map_err(|e| self.err("remove_challenge", e))
223 }
224
225 async fn try_acquire_lease(
226 &self,
227 id: &CertId,
228 owner: &str,
229 ttl: Duration,
230 ) -> Result<bool, AcmeError> {
231 let (client, mut conn) = self.conn().await?;
232 let key = lease_key(client.key_prefix(), id);
233 let acquired: Option<String> = redis::cmd("SET")
234 .arg(&key)
235 .arg(owner)
236 .arg("NX")
237 .arg("PX")
238 .arg(ttl.as_millis().max(1) as u64)
239 .query_async(&mut conn)
240 .await
241 .map_err(|e| self.err("try_acquire_lease", e))?;
242 if acquired.is_some() {
243 return Ok(true);
244 }
245 self.renew_lease(id, owner, ttl).await
247 }
248
249 async fn renew_lease(
250 &self,
251 id: &CertId,
252 owner: &str,
253 ttl: Duration,
254 ) -> Result<bool, AcmeError> {
255 let (client, mut conn) = self.conn().await?;
256 let n: i64 = self
257 .renew_script
258 .key(lease_key(client.key_prefix(), id))
259 .arg(owner)
260 .arg(ttl.as_millis().max(1) as u64)
261 .invoke_async(&mut conn)
262 .await
263 .map_err(|e| self.err("renew_lease", e))?;
264 Ok(n == 1)
265 }
266
267 async fn release_lease(&self, id: &CertId, owner: &str) -> Result<(), AcmeError> {
268 let (client, mut conn) = self.conn().await?;
269 let _: i64 = self
270 .release_script
271 .key(lease_key(client.key_prefix(), id))
272 .arg(owner)
273 .invoke_async(&mut conn)
274 .await
275 .map_err(|e| self.err("release_lease", e))?;
276 Ok(())
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::stores::StoreRegistry;
284 use redis::AsyncCommands;
285
286 fn store_cfg(tag: &str, url: &str) -> crate::config::StoreConfig {
287 serde_yaml::from_str(&format!(
288 "name: acme-live
289type: redis
290url: {url}
291key_prefix: fbacme{tag}{}
292",
293 std::process::id()
294 ))
295 .unwrap()
296 }
297
298 fn live_resources(tag: &str) -> Option<(Arc<PluginResources>, Arc<RedisStoreClient>)> {
301 let Ok(url) = std::env::var("FEATHERBIT_TEST_REDIS_URL") else {
302 eprintln!("skipping: FEATHERBIT_TEST_REDIS_URL not set");
303 return None;
304 };
305 let cfg = store_cfg(tag, &url);
306 let resources = PluginResources::new(None);
307 let registry =
308 StoreRegistry::rebuild(&StoreRegistry::default(), std::slice::from_ref(&cfg), None)
309 .unwrap();
310 let client = registry.client("acme-live").unwrap();
311 resources.stores.store(Arc::new(registry));
312 Some((resources, client))
313 }
314
315 #[tokio::test]
316 async fn redis_storage_satisfies_contract() {
317 let Some((resources, _)) = live_resources("c") else {
318 return;
319 };
320 let storage = Arc::new(RedisCertStorage::new(resources, "acme-live", "test-secret"));
321 assert_eq!(storage.label(), "store:acme-live");
322 crate::acme::storage::contract::run_all(storage).await;
323 }
324
325 #[tokio::test]
326 async fn redis_storage_seals_private_material() {
327 let Some((resources, client)) = live_resources("s") else {
328 return;
329 };
330 let storage = RedisCertStorage::new(resources.clone(), "acme-live", "test-secret");
331 let (id, _) = CertId::from_domains(&["sealed.example.com".into()]).unwrap();
332 let cert = StoredCert {
333 chain_pem: "-----BEGIN CERTIFICATE-----
334AAA
335"
336 .into(),
337 key_pem: "-----BEGIN PRIVATE KEY-----
338SECRET-KEY-BYTES
339"
340 .into(),
341 issued_at: 42,
342 };
343 storage.save_cert(&id, &cert).await.unwrap();
344 storage.save_account(b"ACCOUNT-SECRET").await.unwrap();
345
346 let mut conn = client.conn().await.unwrap();
347 let raw_cert: String = conn.get(cert_key(client.key_prefix(), &id)).await.unwrap();
348 assert!(
349 raw_cert.contains("BEGIN CERTIFICATE"),
350 "chain is stored in clear"
351 );
352 assert!(
353 !raw_cert.contains("SECRET-KEY-BYTES"),
354 "key must be sealed: {raw_cert}"
355 );
356 let raw_acct: String = conn.get(account_key(client.key_prefix())).await.unwrap();
357 assert!(!raw_acct.contains("ACCOUNT-SECRET"));
358
359 let other = RedisCertStorage::new(resources, "acme-live", "wrong");
361 assert!(other.load_cert(&id).await.is_err());
362 assert_eq!(storage.load_cert(&id).await.unwrap().unwrap(), cert);
363 }
364
365 #[tokio::test]
371 async fn client_is_resolved_per_call_so_a_store_edit_is_picked_up() {
372 let Some((resources, first)) = live_resources("swap1") else {
373 return;
374 };
375 let storage = RedisCertStorage::new(resources.clone(), "acme-live", "test-secret");
376 let (id, _) = CertId::from_domains(&["swap.example.com".into()]).unwrap();
377 let cert = StoredCert {
378 chain_pem: "OLD".into(),
379 key_pem: "OLD-KEY".into(),
380 issued_at: 1,
381 };
382 storage.save_cert(&id, &cert).await.unwrap();
383 assert_eq!(storage.load_cert(&id).await.unwrap().unwrap(), cert);
384
385 let url = std::env::var("FEATHERBIT_TEST_REDIS_URL").unwrap();
388 let cfg2 = store_cfg("swap2", &url);
389 let registry2 =
390 StoreRegistry::rebuild(&StoreRegistry::default(), std::slice::from_ref(&cfg2), None)
391 .unwrap();
392 let second = registry2.client("acme-live").unwrap();
393 assert_ne!(first.key_prefix(), second.key_prefix());
394 resources.stores.store(Arc::new(registry2));
395
396 assert!(
399 storage.load_cert(&id).await.unwrap().is_none(),
400 "the next call must use the swapped-in client, not the original"
401 );
402 let fresh = StoredCert {
403 chain_pem: "NEW".into(),
404 key_pem: "NEW-KEY".into(),
405 issued_at: 2,
406 };
407 storage.save_cert(&id, &fresh).await.unwrap();
408 let mut conn = second.conn().await.unwrap();
409 let raw: Option<String> = conn.get(cert_key(second.key_prefix(), &id)).await.unwrap();
410 assert!(
411 raw.is_some_and(|r| r.contains("NEW")),
412 "the write landed under the new client's key_prefix"
413 );
414
415 resources.stores.store(Arc::new(StoreRegistry::default()));
418 let err = storage.load_cert(&id).await.unwrap_err();
419 assert!(
420 matches!(err, AcmeError::Storage(ref m) if m.contains("acme-live")),
421 "{err}"
422 );
423 }
424}