From 38b86cd0e7453ac0d65bc4119e92d14712afad7d Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 11 Feb 2026 16:30:30 -0500 Subject: [PATCH] terrible awful no-good very bad certs revamp --- Cargo.lock | 1 + slingshot/Cargo.toml | 1 + slingshot/src/main.rs | 64 ++++++++++------ slingshot/src/server.rs | 162 ++++++++++++++++++++++++++++++++-------- 4 files changed, 176 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbbd942..d273465 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5101,6 +5101,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" name = "slingshot" version = "0.1.0" dependencies = [ + "async-stream", "atrium-api 0.25.4 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)", "atrium-common 0.1.2 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)", "atrium-identity 0.1.5 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)", diff --git a/slingshot/Cargo.toml b/slingshot/Cargo.toml index e563b19..16ba4e4 100644 --- a/slingshot/Cargo.toml +++ b/slingshot/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +async-stream = "0.3.6" atrium-api = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace", default-features = false } atrium-common = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace" } atrium-identity = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace" } diff --git a/slingshot/src/main.rs b/slingshot/src/main.rs index f6c33bb..e653e35 100644 --- a/slingshot/src/main.rs +++ b/slingshot/src/main.rs @@ -50,38 +50,58 @@ struct Args { /// the address of this server /// /// used if --acme-domain is not set, defaulting to `--bind` - #[arg(long, conflicts_with("acme_domain"), env = "SLINGSHOT_PUBLIC_HOST")] + #[arg(long, conflicts_with("tls_domain"), env = "SLINGSHOT_PUBLIC_HOST")] base_url: Option, /// the domain pointing to this server /// /// if present: /// - a did:web document will be served at /.well-known/did.json - /// - an HTTPS certs will be automatically configured with Acme/letsencrypt + /// - the server will bind on port 443 + /// - if `--acme-contact` is present, the server will bind port 80 for http + /// challenges and attempt to auto-provision certs for `--tls-domain` + /// - if `--acme-contact is absent, the server will load certs from the + /// `--tls-certs` folder, and try to reload them twice daily, guarded by + /// a lock file called `.cert-lock` in the `--tls-certs` folder. /// - TODO: a rate-limiter will be installed #[arg( long, conflicts_with("bind"), - requires("acme_cache_path"), - env = "SLINGSHOT_ACME_DOMAIN" + requires("tls_certs"), + env = "SLINGSHOT_TLS_DOMAIN" )] - acme_domain: Option, + tls_domain: Option, + /// a location to find/cache acme or other tls certs + /// + /// recommended in production, mind the file permissions. + #[arg(long, env = "SLINGSHOT_TLS_CERTS_PATH")] + tls_certs: Option, + /// listen for ipv6 when using acme or other tls + /// + /// you must also configure the relevant DNS records for this to work + #[arg(long, action, requires("tls_domain"), env = "SLINGSHOT_TLS_IPV6")] + tls_ipv6: bool, + /// redirect acme http-01 challenges to this url + /// + /// useful if you're setting up a second instance that synchronizes its + /// certs from a main instance doing acme. + #[arg( + long, + conflicts_with("acme_contact"), + requires("tls_domain"), + env = "SLINGSHOT_ACME_CHALLENGE_REDIRECT" + )] + acme_challenge_redirect: Option, /// email address for letsencrypt contact /// /// recommended in production, i guess? - #[arg(long, requires("acme_domain"), env = "SLINGSHOT_ACME_CONTACT")] + #[arg(long, requires("tls_domain"), env = "SLINGSHOT_ACME_CONTACT")] acme_contact: Option, - /// a location to cache acme https certs - /// - /// required when (and only used when) --acme-domain is specified. + /// use the staging environment for letsencrypt /// - /// recommended in production, but mind the file permissions. - #[arg(long, requires("acme_domain"), env = "SLINGSHOT_ACME_CACHE_PATH")] - acme_cache_path: Option, - /// listen for ipv6 when using acme - /// - /// you must also configure the relevant DNS records for this to work - #[arg(long, action, requires("acme_domain"), env = "SLINGSHOT_ACME_IPV6")] - acme_ipv6: bool, + /// recommended to initially test out new deployments with this to avoid + /// letsencrypt rate limit problems. + #[arg(long, action, requires("acme_contact"), env = "SLINGSHOT_ACME_STAGING")] + acme_staging: bool, /// an web address to send healtcheck pings to every ~51s or so #[arg(long, env = "SLINGSHOT_HEALTHCHECK")] healthcheck: Option, @@ -108,7 +128,7 @@ async fn main() -> Result<(), String> { let base_url: Url = args .base_url .or_else(|| { - args.acme_domain + args.tls_domain .as_ref() .map(|d| Url::parse(&format!("https://{d}")).unwrap()) }) @@ -178,10 +198,12 @@ async fn main() -> Result<(), String> { repo, proxy, base_url, - args.acme_domain, + args.tls_domain, + args.tls_certs, + args.tls_ipv6, + args.acme_challenge_redirect, args.acme_contact, - args.acme_cache_path, - args.acme_ipv6, + args.acme_staging, server_shutdown, bind, ) diff --git a/slingshot/src/server.rs b/slingshot/src/server.rs index 2d322a2..8576156 100644 --- a/slingshot/src/server.rs +++ b/slingshot/src/server.rs @@ -13,12 +13,12 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use poem::{ - Endpoint, EndpointExt, IntoResponse, Route, Server, + Endpoint, EndpointExt, IntoResponse, Route, RouteScheme, Server, endpoint::{StaticFileEndpoint, make_sync}, http::Method, listener::{ Listener, TcpListener, - acme::{AutoCert, LETS_ENCRYPT_PRODUCTION}, + acme::{AutoCert, ChallengeType, LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING}, }, middleware::{CatchPanic, Cors, Tracing}, }; @@ -1311,10 +1311,12 @@ pub async fn serve( repo: Repo, proxy: Proxy, base_url: url::Url, - acme_domain: Option, + tls_domain: Option, + tls_certs: Option, + tls_ipv6: bool, + acme_challenge_redirect: Option, acme_contact: Option, - acme_cache_path: Option, - acme_ipv6: bool, + acme_staging: bool, shutdown: CancellationToken, bind: std::net::SocketAddr, ) -> Result<(), ServerError> { @@ -1331,7 +1333,7 @@ pub async fn serve( "Slingshot", env!("CARGO_PKG_VERSION"), ) - .server(if let Some(ref h) = acme_domain { + .server(if let Some(ref h) = tls_domain { format!("https://{h}") } else { format!("http://{bind}") // yeah should probably fix this for reverse-proxy scenarios but it's ok for dev for now @@ -1347,51 +1349,149 @@ pub async fn serve( "https://microcosm.blue/slingshot", )); - let mut app = Route::new() + let app = Route::new() .at("/", StaticFileEndpoint::new("./static/index.html")) .nest("/openapi", api_service.spec_endpoint()) .nest("/xrpc/", api_service); - if let Some(domain) = acme_domain { + let cors = Cors::new() + .allow_origin_regex("*") + .allow_methods([Method::GET, Method::POST]) + .allow_credentials(false); + + if let Some(domain) = tls_domain { rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("alskfjalksdjf"); - app = app.at("/.well-known/did.json", get_did_doc(&domain)); + let app = app + .at("/.well-known/did.json", get_did_doc(&domain)) + .with(cors); - let mut auto_cert = AutoCert::builder() - .directory_url(LETS_ENCRYPT_PRODUCTION) - .domain(&domain); if let Some(contact) = acme_contact { - auto_cert = auto_cert.contact(contact); - } - if let Some(cache_path) = acme_cache_path { - auto_cert = auto_cert.cache_path(cache_path); + let (listener, app) = acmify(app, domain, tls_certs, tls_ipv6, contact, acme_staging)?; + run(listener, app, shutdown).await + } else { + let certs = tls_certs.expect("certs path must be set for non-acme tls"); + let (listener, app) = tlsify(app, domain, certs, tls_ipv6, acme_challenge_redirect)?; + run(listener, app, shutdown).await } - let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; + } else { + run(TcpListener::bind(bind), app.with(cors), shutdown).await + } +} - run( - TcpListener::bind(if acme_ipv6 { "[::]:443" } else { "0.0.0.0:443" }).acme(auto_cert), - app, - shutdown, - ) - .await +fn acmify( + app: impl Endpoint + 'static, + domain: String, + tls_certs: Option, + tls_ipv6: bool, + acme_contact: String, + acme_staging: bool, +) -> Result<(impl Listener + 'static, impl Endpoint + 'static), ServerError> { + let mut auto_cert = AutoCert::builder() + .contact(acme_contact) + .directory_url(if acme_staging { + LETS_ENCRYPT_STAGING + } else { + LETS_ENCRYPT_PRODUCTION + }) + .domain(&domain) + .challenge_type(ChallengeType::Http01); + + if let Some(path) = tls_certs { + auto_cert = auto_cert.cache_path(path); } else { - run(TcpListener::bind(bind), app, shutdown).await + log::warn!( + "provisioning acme certs without `--tls-certs` folder configured, you might hit letsencrypt rate limits." + ); } + + let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; + + let app = RouteScheme::new() + .https(app) + .http(auto_cert.http_01_endpoint()); + + let listener = TcpListener::bind(if tls_ipv6 { "[::]:443" } else { "0.0.0.0:443" }) + .acme(auto_cert) + .combine(TcpListener::bind(if tls_ipv6 { + "[::]:80" + } else { + "0.0.0.0:80" + })); + + Ok((listener, app)) } -async fn run(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError> +fn tlsify( + app: impl Endpoint + 'static, + domain: String, + tls_certs: PathBuf, + tls_ipv6: bool, + acme_challenge_redirect: Option, +) -> Result<(impl Listener + 'static, impl Endpoint + 'static), ServerError> { + use poem::listener::{RustlsCertificate, RustlsConfig}; + use std::path::Path; + + fn load_tls_config(f: &Path, domain: &str) -> Result { + let cert_contents = std::fs::read(f.join("cert.pem")) + .inspect_err(|e| log::error!("failed to read cert file in {f:?}: {e}"))?; + + let key_contents = std::fs::read(f.join("key.pem")) + .inspect_err(|e| log::error!("failed to read key file in {f:?}: {e}"))?; + + let cert = RustlsCertificate::new() + .cert(cert_contents) + .key(key_contents); + Ok(RustlsConfig::new().certificate(domain, cert)) + } + + let listener = TcpListener::bind(if tls_ipv6 { "[::]:443" } else { "0.0.0.0:443" }) + .rustls(async_stream::stream! { + loop { + if let Ok(tls_config) = load_tls_config(&tls_certs, &domain) { + // TODO: cert reload healthcheck + yield tls_config; + } else { + log::warn!("failed to load tls config."); + } + tokio::time::sleep(std::time::Duration::from_secs(3600 * 12)).await; + } + }) + // TODO: should be allowed to run in tls mode without binding port 80 if not forwarding acme challenges + .combine(TcpListener::bind(if tls_ipv6 { + "[::]:80" + } else { + "0.0.0.0:80" + })); + + let app = if let Some(redir) = acme_challenge_redirect { + use poem::web; + + let redirect = poem::endpoint::make_sync(move |req| { + let token = req.path_params::().unwrap(); + metrics::counter!("http_challenge_redirects").increment(1); + web::Redirect::temporary(format!("{redir}{token}")) + }); + + RouteScheme::new() + .https(app) + .http(Route::new().at("/.well-known/acme-challenge/:token", redirect)) + } else { + // just uh... 404 for port 80? should probably reply with something. + RouteScheme::new().https(app).http(Route::new()) + }; + + Ok((listener, app)) +} + +async fn run(listener: L, app: A, shutdown: CancellationToken) -> Result<(), ServerError> where L: Listener + 'static, + A: Endpoint + 'static, { let app = app - .with( - Cors::new() - .allow_origin_regex("*") - .allow_methods([Method::GET, Method::POST]) - .allow_credentials(false), - ) .with(CatchPanic::new()) .around(request_counter) .with(Tracing); -- knotmirror 0.0.1