This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-edge / src / acme.rs
8.3 kB 276 lines
1use std::convert::Infallible; 2use std::path::PathBuf; 3use std::sync::Arc; 4 5use async_trait::async_trait; 6use futures::StreamExt; 7use knot_types::KnotHostname; 8use rustls::server::ResolvesServerCert; 9use rustls_acme::caches::DirCache; 10use rustls_acme::{AccountCache, AcmeConfig, CertCache}; 11use tokio_util::sync::CancellationToken; 12 13#[derive(Debug, thiserror::Error)] 14pub enum AcmeError { 15 #[error("acme cache {path}: {source}")] 16 Cache { 17 path: String, 18 #[source] 19 source: std::io::Error, 20 }, 21} 22 23struct RestrictedDirCache { 24 dir: PathBuf, 25 inner: DirCache<PathBuf>, 26} 27 28impl RestrictedDirCache { 29 fn new(dir: PathBuf) -> Self { 30 let inner = DirCache::new(dir.clone()); 31 Self { dir, inner } 32 } 33} 34 35#[async_trait] 36impl CertCache for RestrictedDirCache { 37 type EC = std::io::Error; 38 39 async fn load_cert( 40 &self, 41 domains: &[String], 42 directory_url: &str, 43 ) -> Result<Option<Vec<u8>>, Self::EC> { 44 self.inner.load_cert(domains, directory_url).await 45 } 46 47 async fn store_cert( 48 &self, 49 domains: &[String], 50 directory_url: &str, 51 cert: &[u8], 52 ) -> Result<(), Self::EC> { 53 self.inner.store_cert(domains, directory_url, cert).await?; 54 restrict_cache_dir(&self.dir).map_err(std::io::Error::other) 55 } 56} 57 58#[async_trait] 59impl AccountCache for RestrictedDirCache { 60 type EA = std::io::Error; 61 62 async fn load_account( 63 &self, 64 contact: &[String], 65 directory_url: &str, 66 ) -> Result<Option<Vec<u8>>, Self::EA> { 67 self.inner.load_account(contact, directory_url).await 68 } 69 70 async fn store_account( 71 &self, 72 contact: &[String], 73 directory_url: &str, 74 account: &[u8], 75 ) -> Result<(), Self::EA> { 76 self.inner 77 .store_account(contact, directory_url, account) 78 .await?; 79 restrict_cache_dir(&self.dir).map_err(std::io::Error::other) 80 } 81} 82 83#[derive(Debug, thiserror::Error)] 84#[error("acme contact {value:?} isn't a bare email address")] 85pub struct AcmeContactError { 86 value: String, 87} 88 89pub struct AcmeContact(String); 90 91impl AcmeContact { 92 // `mailto()` already puts on the scheme for us, 93 // so someone that came with one 94 // already went out to the ACME account as mailto:mailto:. 95 // Hence no colons. 96 pub fn new(contact: impl Into<String>) -> Result<Self, AcmeContactError> { 97 let contact = contact.into(); 98 let mut halves = contact.split('@'); 99 let well_formed = matches!((halves.next(), halves.next(), halves.next()), (Some(local), Some(domain), None) if !local.is_empty() && domain.contains('.')) 100 && !contact.contains(':') 101 && !contact.chars().any(|c| c.is_whitespace() || c.is_control()); 102 match well_formed { 103 true => Ok(Self(contact)), 104 false => Err(AcmeContactError { value: contact }), 105 } 106 } 107 108 pub fn mailto(&self) -> String { 109 format!("mailto:{}", self.0) 110 } 111} 112 113#[derive(Debug, Clone, PartialEq, Eq)] 114pub struct AcmeCacheDir(PathBuf); 115 116impl AcmeCacheDir { 117 pub fn new(path: impl Into<PathBuf>) -> Self { 118 Self(path.into()) 119 } 120 121 pub fn as_path(&self) -> &std::path::Path { 122 &self.0 123 } 124} 125 126pub struct AcmeParams { 127 pub domains: Vec<KnotHostname>, 128 pub contact: AcmeContact, 129 pub cache_dir: AcmeCacheDir, 130 pub production: bool, 131} 132 133pub fn start( 134 params: AcmeParams, 135 shutdown: CancellationToken, 136) -> Result<Arc<dyn ResolvesServerCert>, AcmeError> { 137 std::fs::create_dir_all(params.cache_dir.as_path()).map_err(|source| AcmeError::Cache { 138 path: params.cache_dir.as_path().display().to_string(), 139 source, 140 })?; 141 restrict_cache_dir(params.cache_dir.as_path())?; 142 143 let mut state = AcmeConfig::<Infallible, Infallible>::new(params.domains) 144 .contact_push(params.contact.mailto()) 145 .cache(RestrictedDirCache::new(params.cache_dir.0)) 146 .directory_lets_encrypt(params.production) 147 .state(); 148 let resolver = state.resolver(); 149 150 tokio::spawn(async move { 151 loop { 152 tokio::select! { 153 () = shutdown.cancelled() => break, 154 event = state.next() => match event { 155 Some(Ok(ok)) => tracing::info!("acme: {ok:?}"), 156 Some(Err(error)) => tracing::warn!("acme: {error:?}"), 157 None => { 158 tracing::warn!("acme renewal stream ended, certificates will no longer renew"); 159 break; 160 } 161 }, 162 } 163 } 164 }); 165 166 Ok(resolver) 167} 168 169#[cfg(unix)] 170fn restrict_cache_dir(path: &std::path::Path) -> Result<(), AcmeError> { 171 use std::os::unix::fs::PermissionsExt; 172 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| { 173 AcmeError::Cache { 174 path: path.display().to_string(), 175 source, 176 } 177 })?; 178 std::fs::read_dir(path) 179 .map_err(|source| AcmeError::Cache { 180 path: path.display().to_string(), 181 source, 182 })? 183 .filter_map(Result::ok) 184 .filter(|entry| { 185 entry 186 .file_type() 187 .map(|kind| kind.is_file()) 188 .unwrap_or(false) 189 }) 190 .try_for_each(|entry| { 191 std::fs::set_permissions(entry.path(), std::fs::Permissions::from_mode(0o600)).map_err( 192 |source| AcmeError::Cache { 193 path: entry.path().display().to_string(), 194 source, 195 }, 196 ) 197 }) 198} 199 200#[cfg(not(unix))] 201fn restrict_cache_dir(_path: &std::path::Path) -> Result<(), AcmeError> { 202 Ok(()) 203} 204 205#[cfg(all(test, unix))] 206mod tests { 207 use super::*; 208 use std::os::unix::fs::PermissionsExt; 209 210 #[test] 211 fn acme_contact_accepts_a_bare_email_and_rejects_everything_else() { 212 assert_eq!( 213 AcmeContact::new("ops@oyster.cafe").unwrap().mailto(), 214 "mailto:ops@oyster.cafe" 215 ); 216 assert!(AcmeContact::new("").is_err()); 217 assert!(AcmeContact::new("ops").is_err()); 218 assert!(AcmeContact::new("ops@localhost").is_err()); 219 assert!(AcmeContact::new("mailto:ops@oyster.cafe").is_err()); 220 assert!(AcmeContact::new("ops@nel.pet@extra.dev").is_err()); 221 assert!(AcmeContact::new("ops @oyster.cafe").is_err()); 222 assert!(AcmeContact::new("@oyster.cafe").is_err()); 223 } 224 225 #[test] 226 fn the_cache_dir_and_its_files_are_tightened_to_owner_only() { 227 let dir = tempfile::tempdir().unwrap(); 228 let key = dir.path().join("account.key"); 229 std::fs::write(&key, b"private material").unwrap(); 230 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap(); 231 232 restrict_cache_dir(dir.path()).unwrap(); 233 234 assert_eq!( 235 std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 236 0o700, 237 "the cache directory must be traversable only by its owner" 238 ); 239 assert_eq!( 240 std::fs::metadata(&key).unwrap().permissions().mode() & 0o777, 241 0o600, 242 "a cached private key must be readable only by its owner" 243 ); 244 } 245 246 #[tokio::test] 247 async fn a_cert_stored_after_boot_is_tightened_to_owner_only() { 248 let dir = tempfile::tempdir().unwrap(); 249 let cache = RestrictedDirCache::new(dir.path().to_path_buf()); 250 cache 251 .store_cert( 252 &["anemone.knot".to_string()], 253 "https://acme.test/directory", 254 b"private cert material", 255 ) 256 .await 257 .unwrap(); 258 259 let modes: Vec<u32> = std::fs::read_dir(dir.path()) 260 .unwrap() 261 .filter_map(Result::ok) 262 .map(|entry| { 263 std::fs::metadata(entry.path()) 264 .unwrap() 265 .permissions() 266 .mode() 267 & 0o777 268 }) 269 .collect(); 270 assert_eq!( 271 modes, 272 vec![0o600], 273 "a certificate written after boot must be the only entry and owner-only" 274 ); 275 } 276}