├── .gitignore ├── Cargo.toml ├── UNLICENSE ├── README.md └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actix-web-lets-encrypt" 3 | version = "0.2.1" 4 | authors = ["Clifford T. Matthews "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | actix = "0.7" 9 | actix-web = { version = "0.7", features = ["alpn"] } 10 | acme-client = { version = "0.5", default-features = false } 11 | openssl = "0.10" 12 | chrono = "0.4" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actix-web-lets-encrypt 2 | 3 | This software makes it fairly easy to use Lets Encrypt with Actix web. 4 | 5 | ## Proof-of-concept 6 | 7 | The code in this repository has been lightly tested, but I am 8 | unhappy with the API I've constructed. I especially dislike the 9 | split between the app_encryption_enabler and the 10 | server_encryption_enabler. I'm new to Rust and wanted to write 11 | *something* and then ask for suggestions for a better API. 12 | 13 | I haven't yet written documentation for the public functions because 14 | I think it's likely they'll change. However, if the example below isn't 15 | sufficient to illustrate the sort of behavior I'm trying to make available 16 | I can go ahead and document what is present. 17 | 18 | This version only works with openssl. 19 | 20 | ```rust 21 | // Although the following code doesn't run as-is, it's basically a 22 | // simplified version of code that has run. Unfortunately, there's no 23 | // way to provide a sample that will run 100% out of the box, because 24 | // to use a certificate you must have DNS pointing a domain to the host 25 | // you're running this on. 26 | #![feature(proc_macro_hygiene)] 27 | 28 | use { 29 | actix_web::{ 30 | actix::Actor, http::Method, server, App, 31 | HttpRequest, HttpResponse, Result, 32 | }, 33 | actix_web_lets_encrypt::{CertBuilder, LetsEncrypt}, 34 | }; 35 | 36 | // ... asset and other non-certificate code elided ... 37 | 38 | fn main() { 39 | let example_prod = CertBuilder::new("0.0.0.0:8089", &["example.com"]).email("ctm@example.com"); 40 | 41 | let two_certs_prod = 42 | CertBuilder::new("0.0.0.0:8090", &["example.org", "example.net"]).email("ctm@example.org"); 43 | 44 | let example_test = CertBuilder::new("0.0.0.0:8091", &["test.example.com"]) 45 | .email("ctm@example.com") 46 | .test(); 47 | 48 | // 8088 is for all http and is bound after we set up the server. 49 | let app_encryption_enabler = LetsEncrypt::encryption_enabler() 50 | .nonce_directory("/var/nonce") 51 | .ssl_directory("ssl") 52 | .add_cert(example_prod) 53 | .add_cert(two_certs_prod) 54 | .add_cert(example_test); 55 | 56 | let server_encryption_enabler = app_encryption_enabler.clone(); 57 | 58 | let mut server = server::new(move || { 59 | App::new().configure(|app| { 60 | let app = app 61 | .resource("/assets/{asset:.*}", |r| r.method(Method::GET).f(asset)) 62 | .resource("/", |r| r.method(Method::GET).f(index)); 63 | app_encryption_enabler.register(app) 64 | }) 65 | }); 66 | 67 | server = server_encryption_enabler 68 | .attach_certificates_to(server) 69 | .bind("0.0.0.0:8088") 70 | .unwrap() 71 | }; 72 | server_encryption_enabler.start(); 73 | server.run(); 74 | } 75 | ``` 76 | 77 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Let's Encrypt SSL support for Actix web applications using acme-client 2 | //! 3 | //! # Proof-of-concept 4 | //! 5 | //! The code in this repository has been lightly tested, but I am 6 | //! unhappy with the API I've constructed. I especially dislike the 7 | //! split between the app_encryption_enabler and the 8 | //! server_encryption_enabler. I'm new to Rust and wanted to write 9 | //! *something* and then ask for suggestions for a better API. 10 | //! 11 | //! I haven't yet written documentation for the public functions because 12 | //! I think it's likely they'll change. However, if the example below isn't 13 | //! sufficient to illustrate the sort of behavior I'm trying to make available 14 | //! I can go ahead and document what is present. 15 | //! 16 | //! This version only works with openssl. 17 | //! 18 | //! ```rust 19 | //! // Although the following code doesn't run as-is, it's basically a 20 | //! // simplified version of code that has run. Unfortunately, there's no 21 | //! // way to provide a sample that will run 100% out of the box, because 22 | //! // to use a certificate you must have DNS pointing a domain to the host 23 | //! // you're running this on. 24 | //! #![feature(proc_macro_hygiene)] 25 | //! 26 | //! use { 27 | //! actix_web::{ 28 | //! actix::Actor, http::Method, server, App, 29 | //! HttpRequest, HttpResponse, Result, 30 | //! }, 31 | //! actix_web_lets_encrypt::{CertBuilder, LetsEncrypt}, 32 | //! }; 33 | //! 34 | //! // ... asset and other non-certificate code elided ... 35 | //! 36 | //! fn main() { 37 | //! let example_prod = CertBuilder::new("0.0.0.0:8089", &["example.com"]).email("ctm@example.com"); 38 | //! 39 | //! let two_certs_prod = 40 | //! CertBuilder::new("0.0.0.0:8090", &["example.org", "example.net"]).email("ctm@example.org"); 41 | //! 42 | //! let example_test = CertBuilder::new("0.0.0.0:8091", &["test.example.com"]) 43 | //! .email("ctm@example.com") 44 | //! .test(); 45 | //! 46 | //! // 8088 is for all http and is bound after we set up the server. 47 | //! let app_encryption_enabler = LetsEncrypt::encryption_enabler() 48 | //! .nonce_directory("/var/nonce") 49 | //! .ssl_directory("ssl") 50 | //! .add_cert(example_prod) 51 | //! .add_cert(two_certs_prod) 52 | //! .add_cert(example_test); 53 | //! 54 | //! let server_encryption_enabler = app_encryption_enabler.clone(); 55 | //! 56 | //! let mut server = server::new(move || { 57 | //! App::new().configure(|app| { 58 | //! let app = app 59 | //! .resource("/assets/{asset:.*}", |r| r.method(Method::GET).f(asset)) 60 | //! .resource("/", |r| r.method(Method::GET).f(index)); 61 | //! app_encryption_enabler.register(app) 62 | //! }) 63 | //! }); 64 | //! 65 | //! server = server_encryption_enabler 66 | //! .attach_certificates_to(server) 67 | //! .bind("0.0.0.0:8088") 68 | //! .unwrap() 69 | //! }; 70 | //! server_encryption_enabler.start(); 71 | //! server.run(); 72 | //! } 73 | //! ``` 74 | 75 | // #![deny(missing_docs)] 76 | 77 | use { 78 | acme_client::{error::Error, Directory}, 79 | actix::prelude::*, 80 | actix_web::{ 81 | self, 82 | fs::NamedFile, 83 | http::Method, 84 | server::{HttpServer, IntoHttpHandler}, 85 | App, HttpRequest, 86 | }, 87 | chrono::{offset::TimeZone, Utc}, 88 | openssl::{ 89 | ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod}, 90 | x509::X509, 91 | }, 92 | std::{ 93 | env, 94 | ffi::OsStr, 95 | fmt::Display, 96 | fs::{self, File}, 97 | io::Read, 98 | net::{SocketAddr, ToSocketAddrs}, 99 | path::{Path, PathBuf}, 100 | str::FromStr, 101 | time::Duration, 102 | }, 103 | }; 104 | 105 | const SECS_IN_MINUTE: u64 = 60; 106 | const SECS_IN_HOUR: u64 = SECS_IN_MINUTE * 60; 107 | const SECS_IN_DAY: u64 = SECS_IN_HOUR * 24; 108 | 109 | #[derive(Clone, Deserialize)] 110 | pub struct CertBuilder { 111 | addrs: Vec, // required 112 | domains: Vec, // required 113 | 114 | #[serde(default)] 115 | email: Option, 116 | 117 | #[serde(default = "CertBuilder::default_production")] 118 | production: bool, 119 | 120 | #[serde(default = "CertBuilder::default_renew_within")] 121 | renew_within: Duration, 122 | 123 | #[serde(default = "CertBuilder::default_check_every")] 124 | check_every: std::time::Duration, 125 | 126 | #[serde(default)] 127 | key_path: Option, 128 | 129 | #[serde(default)] 130 | cert_path: Option, 131 | } 132 | 133 | impl CertBuilder { 134 | pub fn new(addrs: S, domains: &[D]) -> Self 135 | where 136 | S: ToSocketAddrs, 137 | D: AsRef, 138 | { 139 | let addrs = addrs.to_socket_addrs().unwrap().collect(); 140 | let domains = domains.iter().map(|d| d.as_ref().to_string()).collect(); 141 | 142 | CertBuilder { 143 | addrs, 144 | domains, 145 | email: None, 146 | production: Self::default_production(), 147 | renew_within: Self::default_renew_within(), 148 | check_every: Self::default_check_every(), 149 | key_path: None, 150 | cert_path: None, 151 | } 152 | } 153 | 154 | fn default_production() -> bool { 155 | true 156 | } 157 | 158 | fn default_renew_within() -> Duration { 159 | Duration::new(30 * SECS_IN_DAY, 0) 160 | } 161 | 162 | fn default_check_every() -> Duration { 163 | Duration::new(12 * SECS_IN_HOUR, 0) 164 | } 165 | 166 | pub fn email>(mut self, email: E) -> Self { 167 | self.email = Some(email.as_ref().to_string()); 168 | self 169 | } 170 | 171 | pub fn test(mut self) -> Self { 172 | self.production = false; 173 | self 174 | } 175 | 176 | pub fn renew_within(mut self, renewal: &Duration) -> Self { 177 | self.renew_within = *renewal; 178 | self 179 | } 180 | 181 | pub fn check_every(mut self, period: &Duration) -> Self { 182 | self.check_every = *period; 183 | self 184 | } 185 | 186 | fn key_and_cert_present(&self) -> bool { 187 | let key_path = self.key_path.as_ref().unwrap(); 188 | let cert_path = self.cert_path.as_ref().unwrap(); 189 | 190 | fs::metadata(key_path).is_ok() && fs::metadata(cert_path).is_ok() 191 | } 192 | 193 | fn ssl_builder(&self) -> SslAcceptorBuilder { 194 | let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); 195 | builder 196 | .set_private_key_file(self.key_path.clone().unwrap(), SslFiletype::PEM) 197 | .unwrap(); 198 | builder 199 | .set_certificate_chain_file(self.cert_path.clone().unwrap()) 200 | .unwrap(); 201 | builder 202 | } 203 | 204 | fn update_key_path(&mut self, ssl_directory: &PathBuf) { 205 | Self::update_path(&mut self.key_path, "key", ssl_directory, &self.domains); 206 | } 207 | 208 | fn update_cert_path(&mut self, ssl_directory: &PathBuf) { 209 | Self::update_path(&mut self.cert_path, "cert", ssl_directory, &self.domains); 210 | } 211 | 212 | fn update_path( 213 | pathp: &mut Option, 214 | stem: &str, 215 | ssl_directory: &PathBuf, 216 | domains: &[String], 217 | ) { 218 | let mut file; 219 | 220 | match pathp { 221 | None => file = Self::default_file(stem, domains), 222 | Some(path) => { 223 | if path.is_absolute() { 224 | *pathp = Some(path.to_path_buf()); 225 | return; 226 | } else { 227 | file = path.to_path_buf(); 228 | } 229 | } 230 | } 231 | *pathp = Some(ssl_directory.join(file)); 232 | } 233 | 234 | fn default_file(stem: &str, domains: &[String]) -> PathBuf { 235 | PathBuf::from_str(&format!("{}_{}.pem", &domains[0], stem)).unwrap() 236 | } 237 | 238 | fn needs_building(&self) -> bool { 239 | if !self.key_and_cert_present() { 240 | return true; 241 | } 242 | let path = self.cert_path.as_ref().unwrap(); 243 | 244 | let mut f = File::open(path).unwrap(); 245 | let mut cert = Vec::new(); 246 | f.read_to_end(&mut cert).unwrap(); 247 | let cert = X509::from_pem(&cert).ok().unwrap(); 248 | let not_after = cert.not_after().to_string(); 249 | let not_after = Utc 250 | .datetime_from_str(¬_after, "%b %d %H:%M:%S %Y GMT") 251 | .unwrap(); 252 | let time_remaining = not_after.signed_duration_since(Utc::now()); 253 | time_remaining.to_std().unwrap() < self.renew_within 254 | } 255 | } 256 | 257 | use serde::Deserialize; 258 | 259 | #[derive(Clone, Deserialize)] 260 | pub struct LetsEncrypt { 261 | #[serde(default = "LetsEncrypt::default_nonce_directory")] 262 | nonce_directory: PathBuf, 263 | #[serde(default = "LetsEncrypt::default_ssl_directory")] 264 | ssl_directory: PathBuf, 265 | cert_builders: Vec, 266 | } 267 | 268 | impl LetsEncrypt { 269 | pub fn encryption_enabler() -> Self { 270 | Self { 271 | nonce_directory: Self::default_nonce_directory(), 272 | ssl_directory: Self::default_ssl_directory(), 273 | cert_builders: Vec::new(), 274 | } 275 | } 276 | 277 | /// Factory with configuration coming from an environment variable 278 | /// 279 | /// # Arguments 280 | /// 281 | /// * `env_var` - The name of the environment variable whose value is a 282 | /// JSON encoded CertBuilder 283 | /// 284 | /// # Example 285 | /// 286 | /// `SIMPLE_CONFIG='{"cert_builders":[]}'` 287 | /// `COMPLEX_CONFIG='{"nonce_directory":"/var/nonce","ssl_directory":"ssl","cert_builders":[{"addrs":["0.0.0.0:8089"],"domains":["example.com"],"email":"ctm@example.com"},{"addrs":["0.0.0.0:8090"],"domains":["example.org","example.net"],"email":"ctm@example.org"},{"addrs":["0.0.0.0:8091"],"domains":["test.example.com"],"email":"ctm@example.com","production":false}]}'` 288 | /// 289 | /// ```rust 290 | /// let app_encryption_enabler = LetsEncrypt::encryption_enabler_from_env("SIMPLE_CONFIG"); 291 | /// 292 | /// ``` 293 | pub fn encryption_enabler_from_env + Display>(env_var: K) -> Self { 294 | match env::var(&env_var) { 295 | Err(e) => panic!("{}: {}", env_var, e), 296 | Ok(config) => { 297 | let mut enabler: LetsEncrypt = serde_json::from_str(&config) 298 | .unwrap_or_else(|_| panic!("Can't parse {}", env_var)); 299 | 300 | // Although we have the cert builders, we still have to 301 | // add them to the enabler so that the paths will get 302 | // set up properly. This code smells bad. 303 | 304 | for cert in enabler.cert_builders.split_off(0) { 305 | enabler = enabler.add_cert(cert); 306 | } 307 | 308 | enabler 309 | } 310 | } 311 | } 312 | 313 | fn default_nonce_directory() -> PathBuf { 314 | PathBuf::from("/var/tmp/lets_encrypt") 315 | } 316 | 317 | fn default_ssl_directory() -> PathBuf { 318 | PathBuf::from("/ssl") 319 | } 320 | 321 | pub fn nonce_directory

(mut self, path: P) -> Self 322 | where 323 | P: AsRef, 324 | { 325 | self.nonce_directory = PathBuf::from(path.as_ref()); 326 | self 327 | } 328 | 329 | pub fn add_cert(mut self, mut cert: CertBuilder) -> Self { 330 | cert.update_key_path(&self.ssl_directory); 331 | cert.update_cert_path(&self.ssl_directory); 332 | self.cert_builders.push(cert); 333 | self 334 | } 335 | 336 | pub fn ssl_directory

(mut self, path: P) -> Self 337 | where 338 | P: AsRef, 339 | { 340 | self.ssl_directory = PathBuf::from(path.as_ref()); 341 | self 342 | } 343 | 344 | pub fn register(&self, app: App) -> App { 345 | let nonce = self.nonce(); 346 | 347 | app.resource("/.well-known/acme-challenge/{token}", |r| { 348 | r.method(Method::GET).f(nonce) 349 | }) 350 | } 351 | 352 | pub fn attach_certificates_to(&self, mut server: HttpServer) -> HttpServer 353 | where 354 | H: IntoHttpHandler + 'static, 355 | F: Fn() -> H + Send + Clone + 'static, 356 | { 357 | for cert_builder in &self.cert_builders { 358 | if cert_builder.key_and_cert_present() { 359 | server = server 360 | .bind_ssl(&cert_builder.addrs[..], cert_builder.ssl_builder()) 361 | .unwrap(); 362 | } 363 | } 364 | server 365 | } 366 | 367 | fn nonce(&self) -> impl Fn(&HttpRequest) -> actix_web::Result { 368 | let path = PathBuf::from(&self.nonce_directory); 369 | 370 | move |req: &HttpRequest| { 371 | let token: PathBuf = req.match_info().query("token")?; 372 | let path = PathBuf::from(&path) 373 | .join(".well-known") 374 | .join("acme-challenge") 375 | .join(token); 376 | Ok(NamedFile::open(path)?) 377 | } 378 | } 379 | 380 | fn build_cert(&self, cert_builder: &CertBuilder) -> Result<(), Error> { 381 | let directory = if cert_builder.production { 382 | Directory::lets_encrypt() 383 | } else { 384 | Directory::from_url("https://acme-staging.api.letsencrypt.org/directory") 385 | }?; 386 | let mut account = directory.account_registration(); 387 | if let Some(email) = &cert_builder.email { 388 | account = account.email(email); 389 | } 390 | let account = account.register()?; 391 | 392 | for domain in &cert_builder.domains { 393 | let authorization = account.authorization(&domain)?; 394 | let http_challenge = authorization 395 | .get_http_challenge() 396 | .ok_or("HTTP challenge not found")?; 397 | http_challenge.save_key_authorization(self.nonce_directory.clone())?; 398 | http_challenge.validate()?; 399 | } 400 | let domains: Vec<&str> = cert_builder.domains.iter().map(|d| &d[..]).collect(); 401 | let cert = account 402 | .certificate_signer(&domains[..]) 403 | .sign_certificate()?; 404 | cert.save_signed_certificate(&cert_builder.cert_path.as_ref().unwrap())?; 405 | cert.save_private_key(&cert_builder.key_path.as_ref().unwrap())?; 406 | Ok(()) 407 | } 408 | 409 | fn cert_built(&self, cert_builder: &CertBuilder) -> bool { 410 | if cert_builder.needs_building() { 411 | self 412 | .build_cert(cert_builder) 413 | .unwrap_or_else(|e| panic!("could not create cert: {}", e)); 414 | true 415 | } else { 416 | false 417 | } 418 | } 419 | } 420 | 421 | impl Actor for LetsEncrypt { 422 | type Context = Context; 423 | 424 | fn started(&mut self, ctx: &mut Self::Context) { 425 | let mut needs_restart = false; 426 | for cert_builder in &self.cert_builders { 427 | needs_restart = needs_restart || self.cert_built(cert_builder); 428 | } 429 | if needs_restart { 430 | actix::System::current().stop(); 431 | } else { 432 | for cert_builder in &self.cert_builders { 433 | let cert_builder = cert_builder.clone(); 434 | ctx.run_interval(cert_builder.check_every, move |act, _ctx| { 435 | if act.cert_built(&cert_builder) { 436 | actix::System::current().stop(); 437 | } 438 | }); 439 | } 440 | } 441 | } 442 | } 443 | --------------------------------------------------------------------------------