1use std::hash::{Hash, Hasher};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use tracing::warn;
16
17use super::CertStorage;
18use crate::acme::{now_unix, AcmeError, CertId, StoredCert};
19
20pub struct FsCertStorage {
21 dir: PathBuf,
22}
23
24#[derive(Serialize, Deserialize)]
25struct ChallengeFile {
26 key_auth: String,
27 expires_at: i64,
28}
29
30#[derive(Serialize, Deserialize)]
31struct LeaseFile {
32 owner: String,
33 expires_at: i64,
34}
35
36#[derive(Serialize, Deserialize)]
37struct MetaFile {
38 issued_at: i64,
39}
40
41fn io_err(what: &str, path: &Path, e: std::io::Error) -> AcmeError {
42 AcmeError::Storage(format!("{what} {}: {e}", path.display()))
43}
44
45fn atomic_write(path: &Path, bytes: &[u8], secret: bool) -> Result<(), AcmeError> {
48 if let Some(parent) = path.parent() {
49 std::fs::create_dir_all(parent).map_err(|e| io_err("create dir", parent, e))?;
50 }
51 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
52 tmp_name.push(".tmp");
53 let tmp = path.with_file_name(tmp_name);
54 std::fs::write(&tmp, bytes).map_err(|e| io_err("write", &tmp, e))?;
55 #[cfg(unix)]
56 if secret {
57 use std::os::unix::fs::PermissionsExt;
58 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
59 .map_err(|e| io_err("chmod", &tmp, e))?;
60 }
61 #[cfg(not(unix))]
62 let _ = secret;
63 if let Err(first) = std::fs::rename(&tmp, path) {
64 std::fs::remove_file(path).map_err(|e| io_err("replace", path, e))?;
69 std::fs::rename(&tmp, path).map_err(|_| io_err("rename", path, first))?;
70 }
71 Ok(())
72}
73
74fn read_opt(path: &Path) -> Result<Option<Vec<u8>>, AcmeError> {
75 match std::fs::read(path) {
76 Ok(b) => Ok(Some(b)),
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
78 Err(e) => Err(io_err("read", path, e)),
79 }
80}
81
82fn remove_opt(path: &Path) -> Result<(), AcmeError> {
83 match std::fs::remove_file(path) {
84 Ok(()) => Ok(()),
85 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
86 Err(e) => Err(io_err("remove", path, e)),
87 }
88}
89
90fn expires_at(ttl: Duration) -> i64 {
91 now_unix() + ttl.as_secs().max(1) as i64
92}
93
94static LEASE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
98
99fn lease_tmp_path(lease_path: &Path, owner: &str) -> PathBuf {
103 let mut hasher = std::collections::hash_map::DefaultHasher::new();
104 owner.hash(&mut hasher);
105 std::process::id().hash(&mut hasher);
106 std::thread::current().id().hash(&mut hasher);
107 LEASE_TMP_COUNTER
108 .fetch_add(1, Ordering::Relaxed)
109 .hash(&mut hasher);
110 let nanos = std::time::SystemTime::now()
111 .duration_since(std::time::UNIX_EPOCH)
112 .unwrap_or_default()
113 .as_nanos();
114 nanos.hash(&mut hasher);
115 let mut name = lease_path.file_name().unwrap_or_default().to_os_string();
116 name.push(format!(".{:x}.tmp", hasher.finish()));
117 lease_path.with_file_name(name)
118}
119
120impl FsCertStorage {
121 pub fn new(dir: impl Into<PathBuf>) -> Self {
122 Self { dir: dir.into() }
123 }
124
125 fn cert_dir(&self, id: &CertId) -> PathBuf {
126 self.dir.join("certs").join(id.as_str())
127 }
128 fn challenge_path(&self, domain: &str) -> PathBuf {
129 self.dir.join("challenges").join(domain)
130 }
131 fn lease_path(&self, id: &CertId) -> PathBuf {
132 self.dir.join("leases").join(id.as_str())
133 }
134
135 fn read_lease(&self, id: &CertId) -> Result<Option<LeaseFile>, AcmeError> {
136 let Some(bytes) = read_opt(&self.lease_path(id))? else {
137 return Ok(None);
138 };
139 let lease: LeaseFile = match serde_json::from_slice(&bytes) {
140 Ok(l) => l,
141 Err(_) => return Ok(None), };
143 if lease.expires_at <= now_unix() {
144 return Ok(None);
145 }
146 Ok(Some(lease))
147 }
148
149 fn write_lease(&self, id: &CertId, owner: &str, ttl: Duration) -> Result<(), AcmeError> {
150 let lease = LeaseFile {
151 owner: owner.to_string(),
152 expires_at: expires_at(ttl),
153 };
154 atomic_write(
155 &self.lease_path(id),
156 &serde_json::to_vec(&lease).unwrap(),
157 false,
158 )
159 }
160
161 fn create_lease_file(&self, id: &CertId, owner: &str, ttl: Duration) -> std::io::Result<()> {
177 let path = self.lease_path(id);
178 if let Some(parent) = path.parent() {
179 std::fs::create_dir_all(parent)?;
180 }
181 let lease = LeaseFile {
182 owner: owner.to_string(),
183 expires_at: expires_at(ttl),
184 };
185 let bytes = serde_json::to_vec(&lease).unwrap();
186
187 let tmp = lease_tmp_path(&path, owner);
188 std::fs::write(&tmp, &bytes)?;
189
190 let result = match std::fs::hard_link(&tmp, &path) {
191 Ok(()) => Ok(()),
192 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
193 Err(_) => std::fs::OpenOptions::new()
194 .write(true)
195 .create_new(true)
196 .open(&path)
197 .and_then(|mut f| {
198 use std::io::Write;
199 f.write_all(&bytes)
200 }),
201 };
202 let _ = std::fs::remove_file(&tmp);
203 result
204 }
205
206 fn take_over_expired_lease(
224 &self,
225 id: &CertId,
226 owner: &str,
227 ttl: Duration,
228 ) -> Result<bool, AcmeError> {
229 let Some(_guard) = TakeoverGuard::acquire(&self.lease_path(id), owner)? else {
230 return Ok(false);
231 };
232 match self.read_lease(id)? {
235 Some(l) if l.owner != owner => Ok(false),
236 _ => {
237 self.write_lease(id, owner, ttl)?;
238 Ok(true)
239 }
240 }
241 }
242}
243
244const TAKEOVER_MARKER_STALE_SECS: u64 = 60;
248
249struct TakeoverGuard(PathBuf);
251
252impl TakeoverGuard {
253 fn acquire(lease_path: &Path, owner: &str) -> Result<Option<Self>, AcmeError> {
256 let mut name = lease_path.file_name().unwrap_or_default().to_os_string();
257 name.push(".takeover");
258 let marker = lease_path.with_file_name(name);
259 if let Some(parent) = marker.parent() {
260 std::fs::create_dir_all(parent).map_err(|e| io_err("create dir", parent, e))?;
261 }
262 match create_marker(&marker, owner) {
263 Ok(()) => return Ok(Some(Self(marker))),
264 Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => {
265 return Err(io_err("create takeover marker", &marker, e))
266 }
267 Err(_) => {}
268 }
269 let stale = std::fs::metadata(&marker)
274 .and_then(|m| m.modified())
275 .map(|t| {
276 t.elapsed()
277 .map(|d| d.as_secs() >= TAKEOVER_MARKER_STALE_SECS)
278 .unwrap_or(false)
279 })
280 .unwrap_or(false);
281 if !stale {
282 return Ok(None);
283 }
284 warn!(
285 "acme: removing a stale lease-takeover marker at {}",
286 marker.display()
287 );
288 let _ = std::fs::remove_file(&marker);
289 match create_marker(&marker, owner) {
290 Ok(()) => Ok(Some(Self(marker))),
291 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
292 Err(e) => Err(io_err("create takeover marker", &marker, e)),
293 }
294 }
295}
296
297impl Drop for TakeoverGuard {
298 fn drop(&mut self) {
299 let _ = std::fs::remove_file(&self.0);
300 }
301}
302
303fn create_marker(marker: &Path, owner: &str) -> std::io::Result<()> {
305 use std::io::Write;
306 std::fs::OpenOptions::new()
307 .write(true)
308 .create_new(true)
309 .open(marker)
310 .and_then(|mut f| f.write_all(owner.as_bytes()))
311}
312
313#[async_trait]
314impl CertStorage for FsCertStorage {
315 fn label(&self) -> String {
316 "filesystem".to_string()
317 }
318
319 async fn load_account(&self) -> Result<Option<Vec<u8>>, AcmeError> {
320 read_opt(&self.dir.join("account.json"))
321 }
322
323 async fn save_account(&self, creds: &[u8]) -> Result<(), AcmeError> {
324 atomic_write(&self.dir.join("account.json"), creds, true)
325 }
326
327 async fn load_cert(&self, id: &CertId) -> Result<Option<StoredCert>, AcmeError> {
328 let dir = self.cert_dir(id);
329 let (Some(chain), Some(key)) = (
330 read_opt(&dir.join("chain.pem"))?,
331 read_opt(&dir.join("key.pem"))?,
332 ) else {
333 return Ok(None);
334 };
335 let issued_at = read_opt(&dir.join("meta.json"))?
336 .and_then(|b| serde_json::from_slice::<MetaFile>(&b).ok())
337 .map(|m| m.issued_at)
338 .unwrap_or(0);
339 Ok(Some(StoredCert {
340 chain_pem: String::from_utf8_lossy(&chain).into_owned(),
341 key_pem: String::from_utf8_lossy(&key).into_owned(),
342 issued_at,
343 }))
344 }
345
346 async fn save_cert(&self, id: &CertId, cert: &StoredCert) -> Result<(), AcmeError> {
347 let dir = self.cert_dir(id);
348 atomic_write(&dir.join("key.pem"), cert.key_pem.as_bytes(), true)?;
350 atomic_write(
351 &dir.join("meta.json"),
352 &serde_json::to_vec(&MetaFile {
353 issued_at: cert.issued_at,
354 })
355 .unwrap(),
356 false,
357 )?;
358 atomic_write(&dir.join("chain.pem"), cert.chain_pem.as_bytes(), false)
359 }
360
361 async fn put_challenge(
362 &self,
363 domain: &str,
364 key_auth: &str,
365 ttl: Duration,
366 ) -> Result<(), AcmeError> {
367 let file = ChallengeFile {
368 key_auth: key_auth.to_string(),
369 expires_at: expires_at(ttl),
370 };
371 atomic_write(
372 &self.challenge_path(domain),
373 &serde_json::to_vec(&file).unwrap(),
374 false,
375 )
376 }
377
378 async fn get_challenge(&self, domain: &str) -> Result<Option<String>, AcmeError> {
379 let Some(bytes) = read_opt(&self.challenge_path(domain))? else {
380 return Ok(None);
381 };
382 let file: ChallengeFile = match serde_json::from_slice(&bytes) {
383 Ok(f) => f,
384 Err(_) => return Ok(None),
385 };
386 if file.expires_at <= now_unix() {
387 return Ok(None);
388 }
389 Ok(Some(file.key_auth))
390 }
391
392 async fn remove_challenge(&self, domain: &str) -> Result<(), AcmeError> {
393 remove_opt(&self.challenge_path(domain))
394 }
395
396 async fn try_acquire_lease(
397 &self,
398 id: &CertId,
399 owner: &str,
400 ttl: Duration,
401 ) -> Result<bool, AcmeError> {
402 match self.create_lease_file(id, owner, ttl) {
403 Ok(()) => Ok(true),
404 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
405 match self.read_lease(id)? {
406 Some(l) if l.owner != owner => Ok(false),
408 Some(_) => {
410 self.write_lease(id, owner, ttl)?;
411 Ok(true)
412 }
413 None => self.take_over_expired_lease(id, owner, ttl),
416 }
417 }
418 Err(e) => Err(io_err("create lease", &self.lease_path(id), e)),
419 }
420 }
421
422 async fn renew_lease(
423 &self,
424 id: &CertId,
425 owner: &str,
426 ttl: Duration,
427 ) -> Result<bool, AcmeError> {
428 match self.read_lease(id)? {
429 Some(l) if l.owner == owner => {
430 self.write_lease(id, owner, ttl)?;
431 Ok(true)
432 }
433 _ => Ok(false),
434 }
435 }
436
437 async fn release_lease(&self, id: &CertId, owner: &str) -> Result<(), AcmeError> {
438 match self.read_lease(id)? {
439 Some(l) if l.owner == owner => remove_opt(&self.lease_path(id)),
440 _ => Ok(()),
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use std::sync::Arc;
449
450 fn temp_dir(tag: &str) -> std::path::PathBuf {
451 let d = std::env::temp_dir().join(format!("fb_acme_{}_{}", tag, std::process::id()));
452 let _ = std::fs::remove_dir_all(&d);
453 d
454 }
455
456 #[tokio::test]
457 async fn fs_storage_satisfies_contract() {
458 let dir = temp_dir("contract");
459 let storage = Arc::new(FsCertStorage::new(dir.clone()));
460 assert_eq!(storage.label(), "filesystem");
461 crate::acme::storage::contract::run_all(storage).await;
462 let _ = std::fs::remove_dir_all(dir);
463 }
464
465 #[tokio::test]
466 async fn fs_layout_and_atomic_write() {
467 let dir = temp_dir("layout");
468 let storage = FsCertStorage::new(dir.clone());
469 let (id, _) = CertId::from_domains(&["a.example.com".into()]).unwrap();
470 storage
471 .save_cert(
472 &id,
473 &StoredCert {
474 chain_pem: "C".into(),
475 key_pem: "K".into(),
476 issued_at: 1,
477 },
478 )
479 .await
480 .unwrap();
481 let cert_dir = dir.join("certs").join("a.example.com");
482 assert!(cert_dir.join("chain.pem").exists());
483 assert!(cert_dir.join("key.pem").exists());
484 assert!(cert_dir.join("meta.json").exists());
485 assert!(!cert_dir.join("key.pem.tmp").exists());
486 #[cfg(unix)]
487 {
488 use std::os::unix::fs::PermissionsExt;
489 let mode = std::fs::metadata(cert_dir.join("key.pem"))
490 .unwrap()
491 .permissions()
492 .mode();
493 assert_eq!(mode & 0o777, 0o600);
494 }
495 let _ = std::fs::remove_dir_all(dir);
496 }
497
498 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
506 async fn try_acquire_lease_is_atomic_under_concurrency() {
507 const CONTENDERS: usize = 8;
508 const ROUNDS: usize = 20;
509
510 let dir = temp_dir("lease_race");
511 std::fs::create_dir_all(&dir).unwrap();
512 let ttl = Duration::from_secs(30);
513
514 for round in 0..ROUNDS {
515 let (id, _) = CertId::from_domains(&[format!("race-{round}.example.com")]).unwrap();
516 let barrier = Arc::new(std::sync::Barrier::new(CONTENDERS));
517
518 let handles: Vec<_> = (0..CONTENDERS)
519 .map(|i| {
520 let barrier = barrier.clone();
521 let dir = dir.clone();
522 let id = id.clone();
523 std::thread::spawn(move || {
524 let storage = FsCertStorage::new(dir);
525 let rt = tokio::runtime::Builder::new_current_thread()
526 .enable_all()
527 .build()
528 .unwrap();
529 barrier.wait();
530 rt.block_on(storage.try_acquire_lease(&id, &format!("owner-{i}"), ttl))
531 .unwrap()
532 })
533 })
534 .collect();
535
536 let wins: usize = handles
537 .into_iter()
538 .map(|h| h.join().unwrap())
539 .filter(|w| *w)
540 .count();
541 assert_eq!(
542 wins, 1,
543 "round {round}: exactly one concurrent acquirer should win the lease"
544 );
545 }
546
547 let _ = std::fs::remove_dir_all(dir);
548 }
549
550 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
567 async fn expired_lease_takeover_is_atomic_under_concurrency() {
568 const CONTENDERS: usize = 8;
569 const ROUNDS: usize = 10;
570
571 let dir = temp_dir("lease_takeover_race");
572 std::fs::create_dir_all(&dir).unwrap();
573 let ttl = Duration::from_secs(30);
574
575 for round in 0..ROUNDS {
576 let (id, _) = CertId::from_domains(&[format!("stale-{round}.example.com")]).unwrap();
577 let seed = FsCertStorage::new(dir.clone());
580 assert!(seed
581 .try_acquire_lease(&id, "previous-holder", Duration::from_secs(1))
582 .await
583 .unwrap());
584 tokio::time::sleep(Duration::from_millis(1_500)).await;
585 assert!(seed.lease_path(&id).exists(), "the stale lease file stays");
586
587 let barrier = Arc::new(std::sync::Barrier::new(CONTENDERS));
588 let handles: Vec<_> = (0..CONTENDERS)
589 .map(|i| {
590 let barrier = barrier.clone();
591 let dir = dir.clone();
592 let id = id.clone();
593 std::thread::spawn(move || {
594 let storage = FsCertStorage::new(dir);
595 let rt = tokio::runtime::Builder::new_current_thread()
596 .enable_all()
597 .build()
598 .unwrap();
599 barrier.wait();
600 rt.block_on(storage.try_acquire_lease(&id, &format!("owner-{i}"), ttl))
601 .unwrap()
602 })
603 })
604 .collect();
605
606 let wins: usize = handles
607 .into_iter()
608 .map(|h| h.join().unwrap())
609 .filter(|w| *w)
610 .count();
611 assert_eq!(
612 wins, 1,
613 "round {round}: exactly one contender should take over an expired lease"
614 );
615 }
616
617 let _ = std::fs::remove_dir_all(dir);
618 }
619}