diff --git a/.github/workflows/clippy-fmt.yml b/.github/workflows/clippy-fmt.yml index 28a36ce..af782e0 100644 --- a/.github/workflows/clippy-fmt.yml +++ b/.github/workflows/clippy-fmt.yml @@ -50,13 +50,14 @@ jobs: components: clippy override: true - - name: create test db for sqlx - run: | - sudo apt-get update && sudo apt-get install sqlite3 - cd database_interactions/sqlx_todo - cp .env.example .env - cat schema.sql | sqlite3 test.db - chmod a+rwx test.db + # - name: Create test DBs + # run: | + # sudo apt-get update && sudo apt-get install sqlite3 + # cargo install sqlx-cli --no-default-features --features=rustls,sqlite + # cd basics/todo + # DATABASE_URL="sqlite://./todo.db" sqlx database create + # chmod a+rwx todo.db + # DATABASE_URL="sqlite://./todo.db" sqlx migrate run - name: clippy uses: actions-rs/clippy-check@v1 diff --git a/Cargo.lock b/Cargo.lock index 9c73147..6e6f7d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,23 @@ dependencies = [ "regex", ] +[[package]] +name = "acme-micro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18fb402a32ddc56ef7015df8d575c326ea911887ba9b7661ce18786282e89a3" +dependencies = [ + "anyhow", + "base64 0.13.0", + "lazy_static", + "log", + "openssl", + "serde 1.0.136", + "serde_json", + "time 0.1.43", + "ureq", +] + [[package]] name = "actix" version = "0.10.0" @@ -1750,6 +1767,12 @@ dependencies = [ "phf_codegen 0.10.0", ] +[[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + [[package]] name = "cipher" version = "0.2.5" @@ -1934,6 +1957,22 @@ dependencies = [ "log", ] +[[package]] +name = "cookie_store" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3818dfca4b0cb5211a659bbcbb94225b7127407b2b135e650d717bfb78ab10d3" +dependencies = [ + "cookie 0.14.4", + "idna", + "log", + "publicsuffix", + "serde 1.0.136", + "serde_json", + "time 0.2.27", + "url", +] + [[package]] name = "copyless" version = "0.1.5" @@ -4110,6 +4149,21 @@ dependencies = [ "openssl-sys", ] +[[package]] +name = "openssl-auto-le" +version = "1.0.0" +dependencies = [ + "acme-micro", + "actix-files 0.6.0-beta.16", + "actix-web 4.0.0-rc.2", + "anyhow", + "env_logger 0.9.0", + "futures-util", + "log", + "openssl", + "reqwest 0.11.9", +] + [[package]] name = "openssl-example" version = "1.0.0" @@ -4564,6 +4618,25 @@ dependencies = [ "prost-derive", ] +[[package]] +name = "publicsuffix" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4ce31ff0a27d93c8de1849cf58162283752f065a90d508f1105fa6c9a213f" +dependencies = [ + "idna", + "url", +] + +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -4865,6 +4938,42 @@ dependencies = [ "winreg 0.7.0", ] +[[package]] +name = "reqwest" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525" +dependencies = [ + "base64 0.13.0", + "bytes 1.1.0", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.11", + "http", + "http-body 0.4.4", + "hyper 0.14.16", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite 0.2.8", + "serde 1.0.136", + "serde_json", + "serde_urlencoded", + "tokio 1.16.1", + "tokio-native-tls", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.7.0", +] + [[package]] name = "resolv-conf" version = "0.7.0" @@ -5564,7 +5673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba54017cf417e62d64260167de6b8d578f99a248225d3f9fd3396db1ab9e7fbc" dependencies = [ "chrono", - "reqwest", + "reqwest 0.10.10", "serde 1.0.136", "serde_derive", "serde_json", @@ -6627,6 +6736,25 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "ureq" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8b063c2d59218ae09f22b53c42eaad0d53516457905f5235ca4bc9e99daa71" +dependencies = [ + "base64 0.13.0", + "chunked_transfer", + "cookie 0.14.4", + "cookie_store", + "log", + "once_cell", + "qstring", + "rustls 0.19.1", + "url", + "webpki 0.21.4", + "webpki-roots 0.21.1", +] + [[package]] name = "url" version = "2.2.2" diff --git a/Cargo.toml b/Cargo.toml index f0852fd..b14e25e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ members = [ "security/awc_https", "security/casbin", "security/openssl", + "security/openssl-auto-le", "security/rustls-client-cert", "security/rustls", "security/web-cors/backend", diff --git a/security/openssl-auto-le/Cargo.toml b/security/openssl-auto-le/Cargo.toml new file mode 100644 index 0000000..1cbd814 --- /dev/null +++ b/security/openssl-auto-le/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "openssl-auto-le" +version = "1.0.0" +edition = "2021" + +[dependencies] +actix-web = { version = "4.0.0-rc.2", features = ["openssl"] } +actix-files = "0.6.0-beta.16" + +acme-micro = "0.12" +anyhow = "1" +env_logger = "0.9" +futures-util = { version = "0.3.7", default-features = false, features = ["std"] } +log = "0.4" +openssl = { version = "0.10", features = ["v110"] } +reqwest = "0.11" diff --git a/security/openssl-auto-le/README.md b/security/openssl-auto-le/README.md new file mode 100644 index 0000000..9578ad6 --- /dev/null +++ b/security/openssl-auto-le/README.md @@ -0,0 +1,5 @@ +# Automatic Let's Encrypt TLS/SSL (via OpenSSL) + +We use (acme-micro)[https://github.com/kpcyrd/acme-micro] to auto-generate TLS/SSL certificates via OpenSSL for a given domain. + +Process is explained in code. diff --git a/security/openssl-auto-le/src/main.rs b/security/openssl-auto-le/src/main.rs new file mode 100644 index 0000000..91c7a66 --- /dev/null +++ b/security/openssl-auto-le/src/main.rs @@ -0,0 +1,198 @@ +use std::{fs, time::Duration}; + +use acme_micro::{create_p384_key, Certificate, Directory, DirectoryUrl}; +use actix_files::Files; +use actix_web::{rt, web, App, HttpRequest, HttpServer, Responder}; +use anyhow::anyhow; +use openssl::{ + pkey::PKey, + ssl::{SslAcceptor, SslMethod}, + x509::X509, +}; + +pub async fn gen_tls_cert( + user_email: &str, + user_domain: &str, +) -> anyhow::Result { + // Create acme-challenge dir. + fs::create_dir("./acme-challenge").unwrap(); + + let domain = user_domain.to_string(); + + // Create temporary Actix Web server for ACME challenge. + let srv = HttpServer::new(|| { + App::new().service( + Files::new( + // HTTP route + "/.well-known/acme-challenge", + // Server's dir + "acme-challenge", + ) + .show_files_listing(), + ) + }) + .bind((domain, 80))? + .shutdown_timeout(0) + .run(); + + let srv_handle = srv.handle(); + let srv_task = rt::spawn(srv); + + // Use DirectoryUrl::LetsEncryptStaging for dev/testing. + let url = DirectoryUrl::LetsEncrypt; + + // Create a directory entrypoint. + let dir = Directory::from_url(url)?; + + // Our contact addresses; note the `mailto:` + let user_email_mailto: String = "mailto:{email}".replace("{email}", user_email); + let contact = vec![user_email_mailto]; + + // Generate a private key and register an account with our ACME provider. + // We should write it to disk any use `load_account` afterwards. + let acc = dir.register_account(contact.clone())?; + + // Load an account from string + let privkey = acc.acme_private_key_pem()?; + let acc = dir.load_account(&privkey, contact)?; + + // Order a new TLS certificate for the domain. + let mut ord_new = acc.new_order(user_domain, &[])?; + + // If the ownership of the domain have already been + // authorized in a previous order, we might be able to + // skip validation. The ACME API provider decides. + let ord_csr = loop { + // Are we done? + if let Some(ord_csr) = ord_new.confirm_validations() { + break ord_csr; + } + + // Get the possible authorizations (for a single domain + // this will only be one element). + let auths = ord_new.authorizations()?; + + // For HTTP, the challenge is a text file that needs to + // be placed in our web server's root: + // + // /acme-challenge/ + // + // The important thing is that it's accessible over the + // web for the domain we are trying to get a + // certificate for: + // + // http://mydomain.io/.well-known/acme-challenge/ + let chall = auths[0] + .http_challenge() + .ok_or(anyhow!("no HTTP challenge accessible"))?; + + // The token is the filename. + let token = chall.http_token(); + + // The proof is the contents of the file + let proof = chall.http_proof()?; + + // Place the file/contents in the correct place. + let path = format!("acme-challenge/{}", token); + fs::write(&path, &proof)?; + + // After the file is accessible from the web, the calls + // this to tell the ACME API to start checking the + // existence of the proof. + // + // The order at ACME will change status to either + // confirm ownership of the domain, or fail due to the + // not finding the proof. To see the change, we poll + // the API with 5000 milliseconds wait between. + chall.validate(Duration::from_millis(5000))?; + + // Update the state against the ACME API. + ord_new.refresh()?; + }; + + // Ownership is proven. Create a private key for + // the certificate. These are provided for convenience; we + // could provide our own keypair instead if we want. + let pkey_pri = create_p384_key()?; + + // Submit the CSR. This causes the ACME provider to enter a + // state of "processing" that must be polled until the + // certificate is either issued or rejected. Again we poll + // for the status change. + let ord_cert = ord_csr.finalize_pkey(pkey_pri, Duration::from_millis(5000))?; + + // Now download the certificate. Also stores the cert in + // the persistence. + let cert = ord_cert.download_cert()?; + + // Stop temporary server for ACME challenge + srv_handle.stop(true).await; + srv_task.await??; + + // Delete acme-challenge dir + fs::remove_dir_all("./acme-challenge")?; + + Ok(cert) +} + +// "Hello world" example +async fn index(_req: HttpRequest) -> impl Responder { + "Hello world!" +} + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + // IMPORTANT: Use your own email and domain! + let email = "example@example.com"; + let domain = "mydomain.io"; + + // Load keys + // ============================================== + // = IMPORTANT: = + // = This process has to be repeated = + // = before the certificate expires (< 90 days) = + // ============================================== + // Obtain TLS certificate + let cert = gen_tls_cert(email, domain).await?; + let mut ssl_builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; + + // Get and add private key + let pkey_der = PKey::private_key_from_der(&cert.private_key_der()?)?; + ssl_builder.set_private_key(&pkey_der)?; + + // Get and add certificate + let cert_der = X509::from_der(&cert.certificate_der()?)?; + ssl_builder.set_certificate(&cert_der)?; + + // Get and add intermediate certificate to the chain + let icert_url = "https://letsencrypt.org/certs/lets-encrypt-r3.der"; + let icert_bytes = reqwest::get(icert_url).await?.bytes().await?; + let intermediate_cert = X509::from_der(&icert_bytes)?; + ssl_builder.add_extra_chain_cert(intermediate_cert)?; + + // NOTE: + // Storing pkey_der, cert_der and intermediate_cert somewhere + // (in order to avoid unnecessarily regeneration of TLS/SSL) is recommended + + log::info!("starting HTTP server at http://localhost:443"); + + // Start HTTP server! + let srv = HttpServer::new(|| App::new().route("/", web::get().to(index))) + .bind_openssl((domain, 443), ssl_builder)? + .run(); + + let srv_handle = srv.handle(); + + let _auto_shutdown_task = rt::spawn(async move { + // Shutdown server every 4 weeks so that TLS certs can be regenerated if needed. + // This is only appropriate in contexts like Kubernetes which can orchestrate restarts. + rt::time::sleep(Duration::from_secs(60 * 60 * 24 * 28)).await; + srv_handle.stop(true).await; + }); + + srv.await?; + + Ok(()) +}