From a7052e878c080194417468745c022eacd92ddc18 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 26 May 2026 14:54:32 +0300 Subject: [PATCH] feat: tranquil's own TLS handling Lewis: May this revision serve well! --- Cargo.lock | 9 + Cargo.toml | 8 +- crates/tranquil-config/src/lib.rs | 85 +++ .../src/endpoints/authorize/login.rs | 3 +- .../src/endpoints/authorize/mod.rs | 2 +- .../src/endpoints/authorize/registration.rs | 3 +- .../src/endpoints/authorize/two_factor.rs | 3 +- .../src/endpoints/delegation.rs | 5 +- .../tranquil-pds/src/rate_limit/extractor.rs | 4 +- crates/tranquil-pds/src/state.rs | 3 +- crates/tranquil-pds/src/util.rs | 217 ++++++- crates/tranquil-server/Cargo.toml | 9 + crates/tranquil-server/src/main.rs | 43 +- crates/tranquil-server/src/tls.rs | 558 ++++++++++++++++++ docs/2_INSTALL_CONTAINERS.md | 13 +- example.toml | 24 + 16 files changed, 967 insertions(+), 22 deletions(-) create mode 100644 crates/tranquil-server/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 5f8cd77..59235b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7932,13 +7932,22 @@ dependencies = [ name = "tranquil-server" version = "0.6.2" dependencies = [ + "arc-swap", "axum", "clap", "dotenvy", "ed25519-dalek", + "futures-util", "hex", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.37", + "rustls-pemfile", + "thiserror 2.0.18", "tokio", + "tokio-rustls 0.26.4", "tokio-util", + "tower", "tracing", "tracing-subscriber", "tranquil-api", diff --git a/Cargo.toml b/Cargo.toml index 01f4b6e..acdef01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844a unicode-segmentation = "1" aes-gcm = "0.10" +arc-swap = "1" backon = "1" bincode = { version = "2", features = ["serde"] } anyhow = "1.0" @@ -86,6 +87,8 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } hkdf = "0.12" hmac = "0.12" http = "1.4" +hyper = { version = "1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } qrcodegen = "1.8" infer = "0.19" @@ -107,6 +110,8 @@ rand = "0.8" redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } regex = "1" rsa = "0.9" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-pemfile = "2" secrecy = { version = "0.10", features = ["serde"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] } serde = { version = "1.0", features = ["derive"] } @@ -119,8 +124,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid" subtle = "2.5" thiserror = "2.0" tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] } -tokio-util = "0.7.18" +tokio-util = { version = "0.7.18", features = ["rt"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } totp-rs = { version = "5", features = ["qr"] } tower = "0.5" tower-http = { version = "0.6", features = ["fs", "cors"] } diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 1f530b2..b74560a 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -255,6 +255,9 @@ impl TranquilConfig { } } + // -- tls -------------------------------------------------------------- + self.server.tls.validate(&mut errors); + // -- SSO providers ---------------------------------------------------- self.validate_sso_provider("sso.github", &self.sso.github, &mut errors); self.validate_sso_provider("sso.google", &self.sso.google, &mut errors); @@ -481,6 +484,52 @@ pub struct ServerConfig { /// Maximum allowed number of preferences #[config(env = "MAX_PREFERENCES_COUNT", default = 1000)] pub max_preferences_count: usize, + + /// If you're not altering TLS config, you don't have to worry about this. + /// This is the number of trusted reverse proxies in front of Tranquil. + /// We read the client IP used for rate limiting and device records this many hops + /// from the right of the X-Forwarded-For header. + /// When left unset, Tranquil will assume: + /// - 0, if the TLS termination is happening here on Tranquil via the TLS config + /// - 1, if the TLS termination *isn't* happening here. + #[config(env = "TRUSTED_PROXY_COUNT")] + pub trusted_proxy_count: Option, + + #[config(nested)] + pub tls: TlsConfig, +} + +#[derive(Debug, Config)] +pub struct TlsConfig { + /// The path to the TLS cert chain. + /// If you set both this and `key_path`, the server terminates TLS itself rather than expecting + /// a reverse proxy to do it. The certificate and key reload on SIGHUP. + #[config(env = "TLS_CERT_PATH")] + pub cert_path: Option, + + /// Path to the TLS private key. + #[config(env = "TLS_KEY_PATH")] + pub key_path: Option, +} + +impl TlsConfig { + /// The certificate and key paths when both are configured. + pub fn material(&self) -> Option<(&str, &str)> { + match (self.cert_path.as_deref(), self.key_path.as_deref()) { + (Some(cert), Some(key)) => Some((cert, key)), + _ => None, + } + } + + pub fn validate(&self, errors: &mut Vec) { + if self.cert_path.is_some() != self.key_path.is_some() { + errors.push( + "server.tls.cert_path (TLS_CERT_PATH) and server.tls.key_path (TLS_KEY_PATH) \ + must both be set to enable app-level TLS, or both be unset" + .to_string(), + ); + } + } } impl ServerConfig { @@ -1625,6 +1674,42 @@ mod tests { ); } + #[test] + fn tls_validate_accepts_both_paths_unset() { + let mut errors = Vec::new(); + TlsConfig { + cert_path: None, + key_path: None, + } + .validate(&mut errors); + assert!(errors.is_empty(), "expected no errors, got {errors:?}"); + } + + #[test] + fn tls_validate_accepts_both_paths_set() { + let mut errors = Vec::new(); + TlsConfig { + cert_path: Some("/etc/tranquil/cert.pem".to_string()), + key_path: Some("/etc/tranquil/key.pem".to_string()), + } + .validate(&mut errors); + assert!(errors.is_empty(), "expected no errors, got {errors:?}"); + } + + #[test] + fn tls_validate_rejects_cert_without_key() { + let mut errors = Vec::new(); + TlsConfig { + cert_path: Some("/etc/tranquil/cert.pem".to_string()), + key_path: None, + } + .validate(&mut errors); + assert!( + errors.iter().any(|e| e.contains("server.tls")), + "expected server.tls error, got {errors:?}" + ); + } + #[derive(Default)] struct EmailOverrides { from_address: Option<&'static str>, diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs index 19e1ade..eaa6070 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs @@ -310,6 +310,7 @@ pub async fn authorize_post( State(state): State, _rate_limit: OAuthRateLimited, headers: HeaderMap, + client_ip: ClientIp, Json(form): Json, ) -> Response { let json_response = wants_json(&headers); @@ -616,7 +617,7 @@ pub async fn authorize_post( let device_data = DeviceData { session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), - ip_address: extract_client_ip(&headers, None), + ip_address: client_ip.into_string(), last_seen_at: Utc::now(), }; if state diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index 1f0c467..92febc4 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -23,7 +23,7 @@ use tranquil_pds::rate_limit::{ }; use tranquil_pds::state::AppState; use tranquil_pds::types::{Did, Handle, PlainPassword}; -use tranquil_pds::util::extract_client_ip; +use tranquil_pds::util::ClientIp; use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId}; use urlencoding::encode as url_encode; diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs index 9e0ca73..445944e 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs @@ -302,6 +302,7 @@ pub async fn register_complete( pub async fn establish_session( State(state): State, headers: HeaderMap, + client_ip: ClientIp, auth: tranquil_pds::auth::Auth, ) -> Response { let did = &auth.did; @@ -319,7 +320,7 @@ pub async fn establish_session( let device_data = DeviceData { session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), - ip_address: extract_client_ip(&headers, None), + ip_address: client_ip.into_string(), last_seen_at: Utc::now(), }; diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs index 660af6f..2a19aae 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs @@ -75,6 +75,7 @@ pub async fn authorize_2fa_post( State(state): State, _rate_limit: OAuthRateLimited, headers: HeaderMap, + client_ip: ClientIp, Json(form): Json, ) -> Response { let json_error = |status: StatusCode, error: &str, description: &str| -> Response { @@ -251,7 +252,7 @@ pub async fn authorize_2fa_post( let device_data = DeviceData { session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), - ip_address: extract_client_ip(&headers, None), + ip_address: client_ip.into_string(), last_seen_at: Utc::now(), }; if state diff --git a/crates/tranquil-oauth-server/src/endpoints/delegation.rs b/crates/tranquil-oauth-server/src/endpoints/delegation.rs index 15e8a4b..164fd9d 100644 --- a/crates/tranquil-oauth-server/src/endpoints/delegation.rs +++ b/crates/tranquil-oauth-server/src/endpoints/delegation.rs @@ -12,7 +12,7 @@ use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls}; use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit}; use tranquil_pds::state::AppState; use tranquil_pds::types::PlainPassword; -use tranquil_pds::util::extract_client_ip; +use tranquil_pds::util::ClientIp; use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint}; use tranquil_types::{Did, RequestId}; @@ -402,6 +402,7 @@ pub struct DelegationTokenAuthSubmit { pub async fn delegation_auth_token( State(state): State, headers: HeaderMap, + client_ip: ClientIp, auth: Auth, Json(form): Json, ) -> Response { @@ -428,7 +429,7 @@ pub async fn delegation_auth_token( return resp; } - let ip = extract_client_ip(&headers, None); + let ip = client_ip.into_string(); let user_agent = tranquil_pds::util::extract_user_agent(&headers); finalize_delegation_auth( diff --git a/crates/tranquil-pds/src/rate_limit/extractor.rs b/crates/tranquil-pds/src/rate_limit/extractor.rs index de895f4..68a5db7 100644 --- a/crates/tranquil-pds/src/rate_limit/extractor.rs +++ b/crates/tranquil-pds/src/rate_limit/extractor.rs @@ -9,7 +9,7 @@ use axum::{ use crate::api::error::ApiError; use crate::oauth::OAuthError; use crate::state::{AppState, RateLimitKind}; -use crate::util::extract_client_ip; +use crate::util::client_ip_from_parts; pub trait RateLimitPolicy: Send + Sync + 'static { const KIND: RateLimitKind; @@ -173,7 +173,7 @@ impl FromRequestParts parts: &mut Parts, state: &AppState, ) -> Result { - let client_ip = extract_client_ip(&parts.headers, None); + let client_ip = client_ip_from_parts(parts); if !state.check_rate_limit(P::KIND, &client_ip).await { tracing::warn!( diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 61a9641..4569ed8 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -251,8 +251,7 @@ impl AppState { } }; - if cfg.server.invite_code_required - && state.repos.user.count_users().await.unwrap_or(1) == 0 + if cfg.server.invite_code_required && state.repos.user.count_users().await.unwrap_or(1) == 0 { let code = crate::util::gen_invite_code(); tracing::info!( diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index 52fea8e..31bf546 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -7,6 +7,7 @@ use rand::Rng; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::net::SocketAddr; +use std::num::NonZeroUsize; use std::str::FromStr; use std::sync::OnceLock; @@ -96,22 +97,99 @@ pub fn generate_random_token() -> String { URL_SAFE_NO_PAD.encode(bytes) } -pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> String { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ForwardedTrust { + Peer, + Proxies(NonZeroUsize), +} + +fn resolve_trust(configured: Option, terminates_tls: bool) -> ForwardedTrust { + let count = configured.unwrap_or(if terminates_tls { 0 } else { 1 }); + match NonZeroUsize::new(count) { + Some(proxies) => ForwardedTrust::Proxies(proxies), + None => ForwardedTrust::Peer, + } +} + +pub(crate) fn forwarded_trust() -> ForwardedTrust { + match tranquil_config::try_get() { + Some(cfg) => resolve_trust( + cfg.server.trusted_proxy_count, + cfg.server.tls.material().is_some(), + ), + None => ForwardedTrust::Peer, + } +} + +fn forwarded_client_ip(headers: &HeaderMap, trusted: NonZeroUsize) -> Option { if let Some(forwarded) = headers.get("x-forwarded-for") && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() { - return first_ip.trim().to_string(); + let hops: Vec<&str> = value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(client) = hops + .len() + .checked_sub(trusted.get()) + .and_then(|idx| hops.get(idx)) + { + return Some((*client).to_string()); + } } - if let Some(real_ip) = headers.get("x-real-ip") + if trusted.get() == 1 + && let Some(real_ip) = headers.get("x-real-ip") && let Ok(value) = real_ip.to_str() + && !value.trim().is_empty() { - return value.trim().to_string(); + return Some(value.trim().to_string()); + } + None +} + +pub(crate) fn extract_client_ip( + headers: &HeaderMap, + addr: Option, + trust: ForwardedTrust, +) -> String { + if let ForwardedTrust::Proxies(trusted) = trust + && let Some(client) = forwarded_client_ip(headers, trusted) + { + return client; } addr.map(|a| a.ip().to_string()) .unwrap_or_else(|| "unknown".to_string()) } +pub(crate) fn client_ip_from_parts(parts: &axum::http::request::Parts) -> String { + let addr = parts + .extensions + .get::>() + .map(|connect_info| connect_info.0); + extract_client_ip(&parts.headers, addr, forwarded_trust()) +} + +#[derive(Debug, Clone)] +pub struct ClientIp(String); + +impl ClientIp { + pub fn into_string(self) -> String { + self.0 + } +} + +impl axum::extract::FromRequestParts for ClientIp { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + Ok(ClientIp(client_ip_from_parts(parts))) + } +} + pub fn set_discord_bot_username(username: String) { DISCORD_BOT_USERNAME.set(username).ok(); } @@ -227,6 +305,135 @@ pub fn is_self_hosted_did_web_enabled() -> bool { #[cfg(test)] mod tests { use super::*; + use axum::extract::{ConnectInfo, FromRequestParts}; + + fn proxies(count: usize) -> ForwardedTrust { + ForwardedTrust::Proxies(NonZeroUsize::new(count).unwrap()) + } + + #[test] + fn resolve_trust_override_wins_over_tls() { + assert_eq!(resolve_trust(Some(1), true), proxies(1)); + assert_eq!(resolve_trust(Some(3), false), proxies(3)); + assert_eq!(resolve_trust(Some(0), true), ForwardedTrust::Peer); + assert_eq!(resolve_trust(Some(0), false), ForwardedTrust::Peer); + } + + #[test] + fn resolve_trust_infers_from_tls_when_unset() { + assert_eq!(resolve_trust(None, true), ForwardedTrust::Peer); + assert_eq!(resolve_trust(None, false), proxies(1)); + } + + fn parts_with( + header: Option<(&str, &str)>, + peer: Option, + ) -> axum::http::request::Parts { + let mut builder = axum::http::Request::builder(); + if let Some((name, value)) = header { + builder = builder.header(name, value); + } + let mut parts = builder.body(()).unwrap().into_parts().0; + if let Some(addr) = peer { + parts.extensions.insert(ConnectInfo(addr)); + } + parts + } + + #[tokio::test] + async fn client_ip_falls_back_to_peer_socket() { + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + let mut parts = parts_with(None, Some(peer)); + let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(ip.into_string(), "203.0.113.7"); + } + + #[tokio::test] + async fn client_ip_ignores_forwarded_when_config_absent() { + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + let mut parts = parts_with( + Some(("x-forwarded-for", "198.51.100.4, 10.0.0.1")), + Some(peer), + ); + let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(ip.into_string(), "203.0.113.7"); + } + + #[tokio::test] + async fn client_ip_unknown_without_headers_or_peer() { + let mut parts = parts_with(None, None); + let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(ip.into_string(), "unknown"); + } + + #[tokio::test] + async fn client_ip_renders_ipv6_peer_without_brackets() { + let peer: SocketAddr = "[2001:db8::beef]:51000".parse().unwrap(); + let mut parts = parts_with(None, Some(peer)); + let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap(); + assert_eq!(ip.into_string(), "2001:db8::beef"); + } + + #[test] + fn extract_client_ip_single_proxy_takes_rightmost_forwarded_hop() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + "9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(), + ); + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + assert_eq!( + extract_client_ip(&headers, Some(peer), proxies(1)), + "10.0.0.1" + ); + } + + #[test] + fn extract_client_ip_two_proxies_skips_inner_hop() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + "9.9.9.9, 198.51.100.4, 10.0.0.1".parse().unwrap(), + ); + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + assert_eq!( + extract_client_ip(&headers, Some(peer), proxies(2)), + "198.51.100.4" + ); + } + + #[test] + fn extract_client_ip_more_trusted_proxies_than_hops_uses_peer() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "10.0.0.1".parse().unwrap()); + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + assert_eq!( + extract_client_ip(&headers, Some(peer), proxies(2)), + "203.0.113.7" + ); + } + + #[test] + fn extract_client_ip_ignores_forwarded_headers_for_direct_peer() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap()); + headers.insert("x-real-ip", "9.9.9.9".parse().unwrap()); + let peer: SocketAddr = "203.0.113.7:51000".parse().unwrap(); + assert_eq!( + extract_client_ip(&headers, Some(peer), ForwardedTrust::Peer), + "203.0.113.7" + ); + } + + #[test] + fn extract_client_ip_direct_peer_without_socket_is_unknown() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "9.9.9.9".parse().unwrap()); + assert_eq!( + extract_client_ip(&headers, None, ForwardedTrust::Peer), + "unknown" + ); + } #[test] fn test_parse_repeated_query_param_repeated() { diff --git a/crates/tranquil-server/Cargo.toml b/crates/tranquil-server/Cargo.toml index 40cf0f3..497df68 100644 --- a/crates/tranquil-server/Cargo.toml +++ b/crates/tranquil-server/Cargo.toml @@ -12,13 +12,22 @@ tranquil-oauth-server = { workspace = true } tranquil-config = { workspace = true } tranquil-signal = { workspace = true } +arc-swap = { workspace = true } axum = { workspace = true } clap = { workspace = true } dotenvy = { workspace = true } ed25519-dalek = { workspace = true } +futures-util = { workspace = true } hex = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } +tokio-rustls = { workspace = true } tokio-util = { workspace = true } +tower = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index 92c668a..fb646b4 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -14,6 +14,8 @@ use tranquil_pds::scheduled::{ }; use tranquil_pds::state::AppState; +mod tls; + #[derive(Parser)] #[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")] struct Cli { @@ -57,6 +59,13 @@ async fn main() -> ExitCode { eprint!("{e}"); return ExitCode::FAILURE; } + if !*ignore_secrets + && let Some((cert, key)) = config.server.tls.material() + && let Err(e) = tls::load_certified_key(cert, key) + { + eprintln!("TLS material invalid: {e}"); + return ExitCode::FAILURE; + } println!("Configuration is valid."); ExitCode::SUCCESS } @@ -277,11 +286,35 @@ async fn run() -> Result<(), Box> { .await .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; - let server_handle = tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(shutdown.clone().cancelled_owned()) - .await - }); + let server_handle = match cfg.server.tls.material() { + Some((cert_path, key_path)) => { + let initial = tls::load_certified_key(cert_path, key_path) + .map_err(|e| format!("Failed to load TLS material: {e}"))?; + let resolver = Arc::new(tls::ReloadableCertResolver::new(initial)); + let server_config = Arc::new( + tls::build_server_config(resolver.clone()) + .map_err(|e| format!("Failed to build TLS configuration: {e}"))?, + ); + tls::spawn_reload_handler( + resolver, + cert_path.to_string(), + key_path.to_string(), + shutdown.clone(), + ); + info!("TLS termination enabled (h2, http/1.1), reload with SIGHUP"); + let shutdown = shutdown.clone(); + tokio::spawn(tls::serve_tls(listener, app, server_config, shutdown)) + } + None => { + let make_service = app.into_make_service_with_connect_info::(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + axum::serve(listener, make_service) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + }) + } + }; if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint { tokio::spawn(async move { diff --git a/crates/tranquil-server/src/tls.rs b/crates/tranquil-server/src/tls.rs new file mode 100644 index 0000000..2cdfe53 --- /dev/null +++ b/crates/tranquil-server/src/tls.rs @@ -0,0 +1,558 @@ +use std::io::BufReader; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use axum::Router; +use axum::extract::ConnectInfo; +use futures_util::StreamExt; +use hyper::Request; +use hyper::body::Incoming; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto; +use rustls::ServerConfig; +use rustls::crypto::ring; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio_rustls::TlsAcceptor; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use tower::Service; +use tracing::{debug, warn}; + +const SHUTDOWN_GRACE: Duration = Duration::from_secs(10); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_secs(1); +const MAX_CONCURRENT_HANDSHAKES: usize = 512; + +#[derive(Debug, thiserror::Error)] +pub enum TlsError { + #[error("reading {path}: {source}")] + Read { + path: String, + source: std::io::Error, + }, + #[error("parsing {path}: {message}")] + Parse { path: String, message: String }, + #[error("no certificates found in {0}")] + NoCertificates(String), + #[error("no private key found in {0}")] + NoPrivateKey(String), + #[error("unusable private key: {0}")] + SigningKey(String), + #[error("building server config: {0}")] + Config(String), + #[error("certificate and private key do not match: {0}")] + KeyMismatch(String), +} + +pub struct ReloadableCertResolver { + current: ArcSwap, +} + +impl std::fmt::Debug for ReloadableCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReloadableCertResolver") + .finish_non_exhaustive() + } +} + +impl ReloadableCertResolver { + pub fn new(initial: CertifiedKey) -> Self { + Self { + current: ArcSwap::from_pointee(initial), + } + } + + pub fn store(&self, key: CertifiedKey) { + self.current.store(Arc::new(key)); + } +} + +impl ResolvesServerCert for ReloadableCertResolver { + fn resolve(&self, _client_hello: ClientHello<'_>) -> Option> { + Some(self.current.load_full()) + } +} + +pub fn load_certified_key(cert_path: &str, key_path: &str) -> Result { + let certs = load_certs(cert_path)?; + let key = load_private_key(key_path)?; + let signing_key = + ring::sign::any_supported_type(&key).map_err(|e| TlsError::SigningKey(e.to_string()))?; + let certified = CertifiedKey::new(certs, signing_key); + certified + .keys_match() + .map_err(|e| TlsError::KeyMismatch(e.to_string()))?; + Ok(certified) +} + +fn load_certs(path: &str) -> Result>, TlsError> { + let bytes = std::fs::read(path).map_err(|source| TlsError::Read { + path: path.to_string(), + source, + })?; + let mut reader = BufReader::new(bytes.as_slice()); + let certs = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| TlsError::Parse { + path: path.to_string(), + message: e.to_string(), + })?; + match certs.is_empty() { + true => Err(TlsError::NoCertificates(path.to_string())), + false => Ok(certs), + } +} + +fn load_private_key(path: &str) -> Result, TlsError> { + let bytes = std::fs::read(path).map_err(|source| TlsError::Read { + path: path.to_string(), + source, + })?; + let mut reader = BufReader::new(bytes.as_slice()); + rustls_pemfile::private_key(&mut reader) + .map_err(|e| TlsError::Parse { + path: path.to_string(), + message: e.to_string(), + })? + .ok_or_else(|| TlsError::NoPrivateKey(path.to_string())) +} + +pub fn build_server_config( + resolver: Arc, +) -> Result { + let provider = Arc::new(ring::default_provider()); + let mut config = ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| TlsError::Config(e.to_string()))? + .with_no_client_auth() + .with_cert_resolver(resolver); + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +pub fn spawn_reload_handler( + resolver: Arc, + cert_path: String, + key_path: String, + shutdown: CancellationToken, +) { + #[cfg(unix)] + tokio::spawn(async move { + use tokio::signal::unix::{SignalKind, signal}; + let mut hangup = match signal(SignalKind::hangup()) { + Ok(stream) => stream, + Err(e) => { + tracing::error!("Failed to install SIGHUP handler: {e}"); + return; + } + }; + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + received = hangup.recv() => { + if received.is_none() { + break; + } + match load_certified_key(&cert_path, &key_path) { + Ok(key) => { + resolver.store(key); + tracing::info!("TLS certificate and key reloaded"); + } + Err(e) => { + warn!("TLS reload failed, keeping existing certificate: {e}"); + } + } + } + } + } + }); + + #[cfg(not(unix))] + let _ = (resolver, cert_path, key_path, shutdown); +} + +fn is_connection_error(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + ) +} + +pub async fn serve_tls( + listener: TcpListener, + app: Router, + server_config: Arc, + shutdown: CancellationToken, +) -> std::io::Result<()> { + let acceptor = TlsAcceptor::from(server_config); + let tracker = TaskTracker::new(); + let handshake_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_HANDSHAKES)); + + let connections = futures_util::stream::unfold(listener, |listener| async move { + Some((listener.accept().await, listener)) + }); + + connections + .take_until(shutdown.clone().cancelled_owned()) + .for_each(|accepted| { + let acceptor = acceptor.clone(); + let app = app.clone(); + let conn_shutdown = shutdown.clone(); + let limiter = handshake_limiter.clone(); + let tracker = &tracker; + async move { + match accepted { + Ok((tcp, peer)) => { + let permit = tokio::select! { + biased; + _ = conn_shutdown.cancelled() => return, + permit = limiter.acquire_owned() => match permit { + Ok(permit) => permit, + Err(_) => return, + }, + }; + tracker.spawn(serve_connection( + acceptor, + app, + tcp, + peer, + conn_shutdown, + permit, + )); + } + Err(e) if is_connection_error(&e) => { + debug!("TLS accept connection error: {e}"); + } + Err(e) => { + warn!( + "TLS accept failed, pausing {ACCEPT_ERROR_BACKOFF:?} before retry: {e}" + ); + tokio::select! { + _ = tokio::time::sleep(ACCEPT_ERROR_BACKOFF) => {} + _ = conn_shutdown.cancelled() => {} + } + } + } + } + }) + .await; + + tracker.close(); + tracker.wait().await; + Ok(()) +} + +async fn serve_connection( + acceptor: TlsAcceptor, + app: Router, + tcp: TcpStream, + peer: SocketAddr, + shutdown: CancellationToken, + handshake_permit: OwnedSemaphorePermit, +) { + let tls_stream = tokio::select! { + result = tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp)) => match result { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + debug!("TLS handshake with {peer} failed: {e}"); + return; + } + Err(_) => { + debug!("TLS handshake with {peer} timed out after {HANDSHAKE_TIMEOUT:?}"); + return; + } + }, + _ = shutdown.cancelled() => { + debug!("shutdown during TLS handshake with {peer}"); + return; + } + }; + drop(handshake_permit); + + let service = hyper::service::service_fn(move |mut request: Request| { + request.extensions_mut().insert(ConnectInfo(peer)); + app.clone().call(request) + }); + + let builder = auto::Builder::new(TokioExecutor::new()); + let connection = builder.serve_connection_with_upgrades(TokioIo::new(tls_stream), service); + tokio::pin!(connection); + + tokio::select! { + result = connection.as_mut() => { + if let Err(e) = result { + debug!("connection from {peer} ended: {e}"); + } + } + _ = shutdown.cancelled() => { + connection.as_mut().graceful_shutdown(); + match tokio::time::timeout(SHUTDOWN_GRACE, connection.as_mut()).await { + Ok(Err(e)) => debug!("connection from {peer} ended during shutdown: {e}"), + Err(_) => debug!("connection from {peer} did not drain within grace, dropping"), + Ok(Ok(())) => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + const CERT_1: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIBrTCCAVKgAwIBAgIUWIlnxLpgk7qp8We8ya6UW1I7p0MwCgYIKoZIzj0EAwIw\n\ +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyNjEyMTQyMloXDTM2MDUyMzEy\n\ +MTQyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n\ +AQcDQgAEgq5UvmRilQh66D5C+78TdULpCuIrI7dtvBB589iJK8Gq14SW9ewkbiWD\n\ +QrXirV47GPzRnODrDIqFSCa4yH+dz6OBgTB/MB0GA1UdDgQWBBSVcvSAd4XB3SCU\n\ +e8MKSOm9i6yigjAfBgNVHSMEGDAWgBSVcvSAd4XB3SCUe8MKSOm9i6yigjAPBgNV\n\ +HRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAA\n\ +AAAAAYcEfwAAATAKBggqhkjOPQQDAgNJADBGAiEA6pIKG7uRbgzuOCDY1Rm+QCuF\n\ +/UTOjWKrfZhoDnXP+swCIQCV7p6vRSt0GnbRzIIcN8UM68cXDZX+Nk0XofZaN217\n\ +mg==\n\ +-----END CERTIFICATE-----\n"; + + const KEY_1: &str = "-----BEGIN PRIVATE KEY-----\n\ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMobX2BajiDVtV5Ti\n\ +kiJ8qEbduI0HvT/qORtLjjCXQ5OhRANCAASCrlS+ZGKVCHroPkL7vxN1QukK4isj\n\ +t228EHnz2IkrwarXhJb17CRuJYNCteKtXjsY/NGc4OsMioVIJrjIf53P\n\ +-----END PRIVATE KEY-----\n"; + + const CERT_2: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIBrDCCAVKgAwIBAgIUJjaLQsKBClkIbtSmDK9vZ9gCrbQwCgYIKoZIzj0EAwIw\n\ +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyNjEyMTQyMloXDTM2MDUyMzEy\n\ +MTQyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n\ +AQcDQgAEI6ljji6CAII88C48Hu7kzEjnV9gMVs8v8Oom04PfcXPR/GSUc0MYz3y4\n\ +LXZC2yNJl40ynzuXNhisk/mQjYbKYaOBgTB/MB0GA1UdDgQWBBTqLGV3rtN9hiuR\n\ +oHUPNnvkwz/DbDAfBgNVHSMEGDAWgBTqLGV3rtN9hiuRoHUPNnvkwz/DbDAPBgNV\n\ +HRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAA\n\ +AAAAAYcEfwAAATAKBggqhkjOPQQDAgNIADBFAiAMVxuI5vyDYi1RtsyuiB+sIl1D\n\ +SdSOaWIgtxPVs5E0CQIhAIrrra+TPrmE8JrjwJBlsONl3oTlOcfDA9WP/FnYbHuv\n\ +-----END CERTIFICATE-----\n"; + + const KEY_2: &str = "-----BEGIN PRIVATE KEY-----\n\ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyBJsGRjta0gqCcBH\n\ +LI5Q1uj42QD1KUfmkOj+o4jlDlmhRANCAAQjqWOOLoIAgjzwLjwe7uTMSOdX2AxW\n\ +zy/w6ibTg99xc9H8ZJRzQxjPfLgtdkLbI0mXjTKfO5c2GKyT+ZCNhsph\n\ +-----END PRIVATE KEY-----\n"; + + #[derive(Debug)] + struct AcceptAnyServerCert; + + impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } + } + + fn write_temp(contents: &str) -> std::path::PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "tranquil_tls_test_{}_{unique}.pem", + std::process::id() + )); + std::fs::write(&path, contents).expect("write temp pem"); + path + } + + #[test] + fn loads_certificate_and_key() { + let cert = write_temp(CERT_1); + let key = write_temp(KEY_1); + let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()) + .expect("load certified key"); + assert_eq!(certified.cert.len(), 1); + } + + #[test] + fn missing_certificate_file_is_read_error() { + let result = load_certs("/nonexistent/tranquil/cert.pem"); + assert!(matches!(result, Err(TlsError::Read { .. }))); + } + + #[test] + fn empty_certificate_file_has_no_certificates() { + let cert = write_temp(""); + let result = load_certs(cert.to_str().unwrap()); + assert!(matches!(result, Err(TlsError::NoCertificates(_)))); + } + + #[test] + fn certificate_without_key_is_missing_key() { + let cert_only = write_temp(CERT_1); + let result = load_private_key(cert_only.to_str().unwrap()); + assert!(matches!(result, Err(TlsError::NoPrivateKey(_)))); + } + + #[test] + fn server_config_advertises_h2_and_http1() { + let cert = write_temp(CERT_1); + let key = write_temp(KEY_1); + let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap(); + let resolver = Arc::new(ReloadableCertResolver::new(certified)); + let config = build_server_config(resolver).expect("build server config"); + assert_eq!( + config.alpn_protocols, + vec![b"h2".to_vec(), b"http/1.1".to_vec()] + ); + } + + #[test] + fn reload_swaps_the_served_certificate() { + let cert1 = write_temp(CERT_1); + let key1 = write_temp(KEY_1); + let cert2 = write_temp(CERT_2); + let key2 = write_temp(KEY_2); + + let first = load_certified_key(cert1.to_str().unwrap(), key1.to_str().unwrap()).unwrap(); + let resolver = ReloadableCertResolver::new(first); + let before = resolver.current.load_full().cert.clone(); + + let second = load_certified_key(cert2.to_str().unwrap(), key2.to_str().unwrap()).unwrap(); + resolver.store(second); + let after = resolver.current.load_full().cert.clone(); + + assert_ne!(before, after); + } + + #[tokio::test] + async fn terminates_tls_over_ipv6_and_negotiates_alpn() { + use rustls::pki_types::ServerName; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_rustls::TlsConnector; + + let cert = write_temp(CERT_1); + let key = write_temp(KEY_1); + let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap(); + let resolver = Arc::new(ReloadableCertResolver::new(certified)); + let server_config = Arc::new(build_server_config(resolver).unwrap()); + + let app = Router::new().route("/", axum::routing::get(|| async { "ok" })); + let listener = TcpListener::bind("[::1]:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + assert!(addr.is_ipv6(), "expected ipv6 bind, got {addr}"); + + let shutdown = CancellationToken::new(); + let server = tokio::spawn(serve_tls(listener, app, server_config, shutdown.clone())); + + let mut client_config = + rustls::ClientConfig::builder_with_provider(Arc::new(ring::default_provider())) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(); + client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let connector = TlsConnector::from(Arc::new(client_config)); + let server_name = ServerName::try_from("localhost").unwrap(); + + let tcp = TcpStream::connect(addr).await.unwrap(); + let mut tls = connector.connect(server_name, tcp).await.unwrap(); + + let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec); + assert_eq!(alpn, Some(b"http/1.1".to_vec())); + + tls.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + tls.read_to_end(&mut response).await.unwrap(); + let text = String::from_utf8_lossy(&response); + assert!( + text.starts_with("HTTP/1.1 200"), + "unexpected response: {text}" + ); + assert!(text.trim_end().ends_with("ok"), "unexpected body: {text}"); + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), server).await; + } + + #[test] + fn mismatched_cert_and_key_is_rejected() { + let cert = write_temp(CERT_1); + let key = write_temp(KEY_2); + let result = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()); + assert!(matches!(result, Err(TlsError::KeyMismatch(_)))); + } + + #[tokio::test] + async fn negotiates_h2_when_client_offers_only_h2() { + use rustls::pki_types::ServerName; + use tokio_rustls::TlsConnector; + + let cert = write_temp(CERT_1); + let key = write_temp(KEY_1); + let certified = load_certified_key(cert.to_str().unwrap(), key.to_str().unwrap()).unwrap(); + let resolver = Arc::new(ReloadableCertResolver::new(certified)); + let server_config = Arc::new(build_server_config(resolver).unwrap()); + + let app = Router::new().route("/", axum::routing::get(|| async { "ok" })); + let listener = TcpListener::bind("[::1]:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let shutdown = CancellationToken::new(); + let server = tokio::spawn(serve_tls(listener, app, server_config, shutdown.clone())); + + let mut client_config = + rustls::ClientConfig::builder_with_provider(Arc::new(ring::default_provider())) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(); + client_config.alpn_protocols = vec![b"h2".to_vec()]; + let connector = TlsConnector::from(Arc::new(client_config)); + let server_name = ServerName::try_from("localhost").unwrap(); + + let tcp = TcpStream::connect(addr).await.unwrap(); + let tls = connector.connect(server_name, tcp).await.unwrap(); + + let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec); + assert_eq!(alpn, Some(b"h2".to_vec())); + + shutdown.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), server).await; + } +} diff --git a/docs/2_INSTALL_CONTAINERS.md b/docs/2_INSTALL_CONTAINERS.md index 58c4375..a295745 100644 --- a/docs/2_INSTALL_CONTAINERS.md +++ b/docs/2_INSTALL_CONTAINERS.md @@ -21,7 +21,18 @@ This guide covers deploying Tranquil PDS using containers with podman. The bundled pod ships nginx as its reverse proxy, and this guide uses it throughout. nginx is just the default. Swap in whatever you prefer. Tranquil serves its API and web UI on a single port, so any reverse proxy works once it forwards to the app on `[::1]:3000`. -Caddy is one good option. It grabs & renews TLS certificates automatically, including the wildcard this setup needs (if you're lucky, which Lewis is not), so the manual certbot steps later become unnecessary. We plan to soon also have the ability to terminate TLS directly in-Tranquil so that there's no reverse proxy needed. +Caddy is one good option. It grabs & renews TLS certificates automatically, including the wildcard this setup needs (if you're lucky, which Lewis is not), so the manual certbot steps later become unnecessary. + +### Terminating TLS in Tranquil + +You can also skip the reverse proxy and let Tranquil terminate TLS itself. Set `TLS_CERT_PATH` and `TLS_KEY_PATH` (or the `[server.tls]` block in the config file) to your cert chain and private key. + +Tranquil does not request or renew certs. Keep using certbot, acme.sh, lego, step, or whatever you're already using. After each renewal, send the process `SIGHUP` to reload the certificates and key. Live requests are unaffected and new connections pick up the new cert. + +### Client IP and forwarded headers + +Rate limiting and device records are based on the client IP ofc. Behind a reverse proxy, Tranquil reads it from `X-Forwarded-For`, counting hops from the right. You can set `TRUSTED_PROXY_COUNT` for how many proxies to trust. Leave it unset to let Tranquil assume the count. We assumes 1 proxy when something else terminates TLS, and 0 when Tranquil terminates TLS itself. At 0 it uses the direct conn address and ignores forwarded headers that a direct client could maliciously invent. + ## Quickstart (docker/podman compose) diff --git a/example.toml b/example.toml index c0dd6aa..0872356 100644 --- a/example.toml +++ b/example.toml @@ -104,6 +104,30 @@ # Default value: 1000 #max_preferences_count = 1000 +# If you're not altering TLS config, you don't have to worry about this. +# This is the number of trusted reverse proxies in front of Tranquil. +# We read the client IP used for rate limiting and device records this many hops +# from the right of the X-Forwarded-For header. +# When left unset, Tranquil will assume: +# - 0, if the TLS termination is happening here on Tranquil via the TLS config +# - 1, if the TLS termination *isn't* happening here. +# +# Can also be specified via environment variable `TRUSTED_PROXY_COUNT`. +#trusted_proxy_count = + +[server.tls] +# The path to the TLS cert chain. +# If you set both this and `key_path`, the server terminates TLS itself rather than expecting +# a reverse proxy to do it. The certificate and key reload on SIGHUP. +# +# Can also be specified via environment variable `TLS_CERT_PATH`. +#cert_path = + +# Path to the TLS private key. +# +# Can also be specified via environment variable `TLS_KEY_PATH`. +#key_path = + [frontend] # Whether to enable the built in serving of the frontend. #