Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
75
fork

Configure Feed

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

terrible awful no-good very bad certs revamp

phil 38b86cd0 ad6cc268

+176 -52
+1
Cargo.lock
··· 5101 5101 name = "slingshot" 5102 5102 version = "0.1.0" 5103 5103 dependencies = [ 5104 + "async-stream", 5104 5105 "atrium-api 0.25.4 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)", 5105 5106 "atrium-common 0.1.2 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)", 5106 5107 "atrium-identity 0.1.5 (git+https://github.com/uniphil/atrium.git?branch=fix%2Fresolve-handle-https-accept-whitespace)",
+1
slingshot/Cargo.toml
··· 4 4 edition = "2024" 5 5 6 6 [dependencies] 7 + async-stream = "0.3.6" 7 8 atrium-api = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace", default-features = false } 8 9 atrium-common = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace" } 9 10 atrium-identity = { git = "https://github.com/uniphil/atrium.git", branch = "fix/resolve-handle-https-accept-whitespace" }
+43 -21
slingshot/src/main.rs
··· 50 50 /// the address of this server 51 51 /// 52 52 /// used if --acme-domain is not set, defaulting to `--bind` 53 - #[arg(long, conflicts_with("acme_domain"), env = "SLINGSHOT_PUBLIC_HOST")] 53 + #[arg(long, conflicts_with("tls_domain"), env = "SLINGSHOT_PUBLIC_HOST")] 54 54 base_url: Option<Url>, 55 55 /// the domain pointing to this server 56 56 /// 57 57 /// if present: 58 58 /// - a did:web document will be served at /.well-known/did.json 59 - /// - an HTTPS certs will be automatically configured with Acme/letsencrypt 59 + /// - the server will bind on port 443 60 + /// - if `--acme-contact` is present, the server will bind port 80 for http 61 + /// challenges and attempt to auto-provision certs for `--tls-domain` 62 + /// - if `--acme-contact is absent, the server will load certs from the 63 + /// `--tls-certs` folder, and try to reload them twice daily, guarded by 64 + /// a lock file called `.cert-lock` in the `--tls-certs` folder. 60 65 /// - TODO: a rate-limiter will be installed 61 66 #[arg( 62 67 long, 63 68 conflicts_with("bind"), 64 - requires("acme_cache_path"), 65 - env = "SLINGSHOT_ACME_DOMAIN" 69 + requires("tls_certs"), 70 + env = "SLINGSHOT_TLS_DOMAIN" 66 71 )] 67 - acme_domain: Option<String>, 72 + tls_domain: Option<String>, 73 + /// a location to find/cache acme or other tls certs 74 + /// 75 + /// recommended in production, mind the file permissions. 76 + #[arg(long, env = "SLINGSHOT_TLS_CERTS_PATH")] 77 + tls_certs: Option<PathBuf>, 78 + /// listen for ipv6 when using acme or other tls 79 + /// 80 + /// you must also configure the relevant DNS records for this to work 81 + #[arg(long, action, requires("tls_domain"), env = "SLINGSHOT_TLS_IPV6")] 82 + tls_ipv6: bool, 83 + /// redirect acme http-01 challenges to this url 84 + /// 85 + /// useful if you're setting up a second instance that synchronizes its 86 + /// certs from a main instance doing acme. 87 + #[arg( 88 + long, 89 + conflicts_with("acme_contact"), 90 + requires("tls_domain"), 91 + env = "SLINGSHOT_ACME_CHALLENGE_REDIRECT" 92 + )] 93 + acme_challenge_redirect: Option<String>, 68 94 /// email address for letsencrypt contact 69 95 /// 70 96 /// recommended in production, i guess? 71 - #[arg(long, requires("acme_domain"), env = "SLINGSHOT_ACME_CONTACT")] 97 + #[arg(long, requires("tls_domain"), env = "SLINGSHOT_ACME_CONTACT")] 72 98 acme_contact: Option<String>, 73 - /// a location to cache acme https certs 74 - /// 75 - /// required when (and only used when) --acme-domain is specified. 76 - /// 77 - /// recommended in production, but mind the file permissions. 78 - #[arg(long, requires("acme_domain"), env = "SLINGSHOT_ACME_CACHE_PATH")] 79 - acme_cache_path: Option<PathBuf>, 80 - /// listen for ipv6 when using acme 99 + /// use the staging environment for letsencrypt 81 100 /// 82 - /// you must also configure the relevant DNS records for this to work 83 - #[arg(long, action, requires("acme_domain"), env = "SLINGSHOT_ACME_IPV6")] 84 - acme_ipv6: bool, 101 + /// recommended to initially test out new deployments with this to avoid 102 + /// letsencrypt rate limit problems. 103 + #[arg(long, action, requires("acme_contact"), env = "SLINGSHOT_ACME_STAGING")] 104 + acme_staging: bool, 85 105 /// an web address to send healtcheck pings to every ~51s or so 86 106 #[arg(long, env = "SLINGSHOT_HEALTHCHECK")] 87 107 healthcheck: Option<String>, ··· 108 128 let base_url: Url = args 109 129 .base_url 110 130 .or_else(|| { 111 - args.acme_domain 131 + args.tls_domain 112 132 .as_ref() 113 133 .map(|d| Url::parse(&format!("https://{d}")).unwrap()) 114 134 }) ··· 178 198 repo, 179 199 proxy, 180 200 base_url, 181 - args.acme_domain, 201 + args.tls_domain, 202 + args.tls_certs, 203 + args.tls_ipv6, 204 + args.acme_challenge_redirect, 182 205 args.acme_contact, 183 - args.acme_cache_path, 184 - args.acme_ipv6, 206 + args.acme_staging, 185 207 server_shutdown, 186 208 bind, 187 209 )
+131 -31
slingshot/src/server.rs
··· 13 13 use tokio_util::sync::CancellationToken; 14 14 15 15 use poem::{ 16 - Endpoint, EndpointExt, IntoResponse, Route, Server, 16 + Endpoint, EndpointExt, IntoResponse, Route, RouteScheme, Server, 17 17 endpoint::{StaticFileEndpoint, make_sync}, 18 18 http::Method, 19 19 listener::{ 20 20 Listener, TcpListener, 21 - acme::{AutoCert, LETS_ENCRYPT_PRODUCTION}, 21 + acme::{AutoCert, ChallengeType, LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING}, 22 22 }, 23 23 middleware::{CatchPanic, Cors, Tracing}, 24 24 }; ··· 1311 1311 repo: Repo, 1312 1312 proxy: Proxy, 1313 1313 base_url: url::Url, 1314 - acme_domain: Option<String>, 1314 + tls_domain: Option<String>, 1315 + tls_certs: Option<PathBuf>, 1316 + tls_ipv6: bool, 1317 + acme_challenge_redirect: Option<String>, 1315 1318 acme_contact: Option<String>, 1316 - acme_cache_path: Option<PathBuf>, 1317 - acme_ipv6: bool, 1319 + acme_staging: bool, 1318 1320 shutdown: CancellationToken, 1319 1321 bind: std::net::SocketAddr, 1320 1322 ) -> Result<(), ServerError> { ··· 1331 1333 "Slingshot", 1332 1334 env!("CARGO_PKG_VERSION"), 1333 1335 ) 1334 - .server(if let Some(ref h) = acme_domain { 1336 + .server(if let Some(ref h) = tls_domain { 1335 1337 format!("https://{h}") 1336 1338 } else { 1337 1339 format!("http://{bind}") // yeah should probably fix this for reverse-proxy scenarios but it's ok for dev for now ··· 1347 1349 "https://microcosm.blue/slingshot", 1348 1350 )); 1349 1351 1350 - let mut app = Route::new() 1352 + let app = Route::new() 1351 1353 .at("/", StaticFileEndpoint::new("./static/index.html")) 1352 1354 .nest("/openapi", api_service.spec_endpoint()) 1353 1355 .nest("/xrpc/", api_service); 1354 1356 1355 - if let Some(domain) = acme_domain { 1357 + let cors = Cors::new() 1358 + .allow_origin_regex("*") 1359 + .allow_methods([Method::GET, Method::POST]) 1360 + .allow_credentials(false); 1361 + 1362 + if let Some(domain) = tls_domain { 1356 1363 rustls::crypto::aws_lc_rs::default_provider() 1357 1364 .install_default() 1358 1365 .expect("alskfjalksdjf"); 1359 1366 1360 - app = app.at("/.well-known/did.json", get_did_doc(&domain)); 1367 + let app = app 1368 + .at("/.well-known/did.json", get_did_doc(&domain)) 1369 + .with(cors); 1361 1370 1362 - let mut auto_cert = AutoCert::builder() 1363 - .directory_url(LETS_ENCRYPT_PRODUCTION) 1364 - .domain(&domain); 1365 1371 if let Some(contact) = acme_contact { 1366 - auto_cert = auto_cert.contact(contact); 1372 + let (listener, app) = acmify(app, domain, tls_certs, tls_ipv6, contact, acme_staging)?; 1373 + run(listener, app, shutdown).await 1374 + } else { 1375 + let certs = tls_certs.expect("certs path must be set for non-acme tls"); 1376 + let (listener, app) = tlsify(app, domain, certs, tls_ipv6, acme_challenge_redirect)?; 1377 + run(listener, app, shutdown).await 1367 1378 } 1368 - if let Some(cache_path) = acme_cache_path { 1369 - auto_cert = auto_cert.cache_path(cache_path); 1370 - } 1371 - let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; 1379 + } else { 1380 + run(TcpListener::bind(bind), app.with(cors), shutdown).await 1381 + } 1382 + } 1383 + 1384 + fn acmify( 1385 + app: impl Endpoint + 'static, 1386 + domain: String, 1387 + tls_certs: Option<PathBuf>, 1388 + tls_ipv6: bool, 1389 + acme_contact: String, 1390 + acme_staging: bool, 1391 + ) -> Result<(impl Listener + 'static, impl Endpoint + 'static), ServerError> { 1392 + let mut auto_cert = AutoCert::builder() 1393 + .contact(acme_contact) 1394 + .directory_url(if acme_staging { 1395 + LETS_ENCRYPT_STAGING 1396 + } else { 1397 + LETS_ENCRYPT_PRODUCTION 1398 + }) 1399 + .domain(&domain) 1400 + .challenge_type(ChallengeType::Http01); 1372 1401 1373 - run( 1374 - TcpListener::bind(if acme_ipv6 { "[::]:443" } else { "0.0.0.0:443" }).acme(auto_cert), 1375 - app, 1376 - shutdown, 1377 - ) 1378 - .await 1402 + if let Some(path) = tls_certs { 1403 + auto_cert = auto_cert.cache_path(path); 1379 1404 } else { 1380 - run(TcpListener::bind(bind), app, shutdown).await 1405 + log::warn!( 1406 + "provisioning acme certs without `--tls-certs` folder configured, you might hit letsencrypt rate limits." 1407 + ); 1381 1408 } 1409 + 1410 + let auto_cert = auto_cert.build().map_err(ServerError::AcmeBuildError)?; 1411 + 1412 + let app = RouteScheme::new() 1413 + .https(app) 1414 + .http(auto_cert.http_01_endpoint()); 1415 + 1416 + let listener = TcpListener::bind(if tls_ipv6 { "[::]:443" } else { "0.0.0.0:443" }) 1417 + .acme(auto_cert) 1418 + .combine(TcpListener::bind(if tls_ipv6 { 1419 + "[::]:80" 1420 + } else { 1421 + "0.0.0.0:80" 1422 + })); 1423 + 1424 + Ok((listener, app)) 1382 1425 } 1383 1426 1384 - async fn run<L>(listener: L, app: Route, shutdown: CancellationToken) -> Result<(), ServerError> 1427 + fn tlsify( 1428 + app: impl Endpoint + 'static, 1429 + domain: String, 1430 + tls_certs: PathBuf, 1431 + tls_ipv6: bool, 1432 + acme_challenge_redirect: Option<String>, 1433 + ) -> Result<(impl Listener + 'static, impl Endpoint + 'static), ServerError> { 1434 + use poem::listener::{RustlsCertificate, RustlsConfig}; 1435 + use std::path::Path; 1436 + 1437 + fn load_tls_config(f: &Path, domain: &str) -> Result<RustlsConfig, std::io::Error> { 1438 + let cert_contents = std::fs::read(f.join("cert.pem")) 1439 + .inspect_err(|e| log::error!("failed to read cert file in {f:?}: {e}"))?; 1440 + 1441 + let key_contents = std::fs::read(f.join("key.pem")) 1442 + .inspect_err(|e| log::error!("failed to read key file in {f:?}: {e}"))?; 1443 + 1444 + let cert = RustlsCertificate::new() 1445 + .cert(cert_contents) 1446 + .key(key_contents); 1447 + Ok(RustlsConfig::new().certificate(domain, cert)) 1448 + } 1449 + 1450 + let listener = TcpListener::bind(if tls_ipv6 { "[::]:443" } else { "0.0.0.0:443" }) 1451 + .rustls(async_stream::stream! { 1452 + loop { 1453 + if let Ok(tls_config) = load_tls_config(&tls_certs, &domain) { 1454 + // TODO: cert reload healthcheck 1455 + yield tls_config; 1456 + } else { 1457 + log::warn!("failed to load tls config."); 1458 + } 1459 + tokio::time::sleep(std::time::Duration::from_secs(3600 * 12)).await; 1460 + } 1461 + }) 1462 + // TODO: should be allowed to run in tls mode without binding port 80 if not forwarding acme challenges 1463 + .combine(TcpListener::bind(if tls_ipv6 { 1464 + "[::]:80" 1465 + } else { 1466 + "0.0.0.0:80" 1467 + })); 1468 + 1469 + let app = if let Some(redir) = acme_challenge_redirect { 1470 + use poem::web; 1471 + 1472 + let redirect = poem::endpoint::make_sync(move |req| { 1473 + let token = req.path_params::<String>().unwrap(); 1474 + metrics::counter!("http_challenge_redirects").increment(1); 1475 + web::Redirect::temporary(format!("{redir}{token}")) 1476 + }); 1477 + 1478 + RouteScheme::new() 1479 + .https(app) 1480 + .http(Route::new().at("/.well-known/acme-challenge/:token", redirect)) 1481 + } else { 1482 + // just uh... 404 for port 80? should probably reply with something. 1483 + RouteScheme::new().https(app).http(Route::new()) 1484 + }; 1485 + 1486 + Ok((listener, app)) 1487 + } 1488 + 1489 + async fn run<L, A>(listener: L, app: A, shutdown: CancellationToken) -> Result<(), ServerError> 1385 1490 where 1386 1491 L: Listener + 'static, 1492 + A: Endpoint + 'static, 1387 1493 { 1388 1494 let app = app 1389 - .with( 1390 - Cors::new() 1391 - .allow_origin_regex("*") 1392 - .allow_methods([Method::GET, Method::POST]) 1393 - .allow_credentials(false), 1394 - ) 1395 1495 .with(CatchPanic::new()) 1396 1496 .around(request_counter) 1397 1497 .with(Tracing);