diff --git a/Cargo.lock b/Cargo.lock index 31fb9c24..b62f1b33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,9 +1496,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1887,6 +1900,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -2854,6 +2873,8 @@ dependencies = [ "rig-core", "rust_decimal", "rust_decimal_macros", + "rustls", + "rustls-native-certs", "rustyline", "secrecy", "secret-service", @@ -2870,6 +2891,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "tokio-stream", "tokio-test", "tokio-tungstenite 0.26.2", @@ -5717,6 +5739,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.49.0" @@ -5792,6 +5835,21 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7528,6 +7586,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7707,6 +7777,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 453f023a..25359d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,9 @@ deadpool-postgres = { version = "0.14", optional = true } tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true } postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true } refinery = { version = "0.8", features = ["tokio-postgres"], optional = true } +tokio-postgres-rustls = { version = "0.13", optional = true } +rustls = { version = "0.23", optional = true, default-features = false } +rustls-native-certs = { version = "0.8", optional = true } # Database - libSQL/Turso (optional embedded database) libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] } @@ -179,6 +182,9 @@ default = ["postgres", "libsql", "html-to-markdown"] postgres = [ "dep:deadpool-postgres", "dep:tokio-postgres", + "dep:tokio-postgres-rustls", + "dep:rustls", + "dep:rustls-native-certs", "dep:postgres-types", "dep:refinery", "dep:pgvector", diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index a1b32b6b..6648a86f 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -171,11 +171,7 @@ async fn try_pg_connect() -> Result<(), String> { url: Some(url), ..Default::default() }; - let pool = config - .create_pool( - Some(deadpool_postgres::Runtime::Tokio1), - tokio_postgres::NoTls, - ) + let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env()) .map_err(|e| format!("pool error: {e}"))?; let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get()) diff --git a/src/cli/status.rs b/src/cli/status.rs index 99e7b27e..4a495141 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -169,11 +169,7 @@ async fn check_database() -> anyhow::Result<()> { url: Some(url), ..Default::default() }; - let pool = config - .create_pool( - Some(deadpool_postgres::Runtime::Tokio1), - tokio_postgres::NoTls, - ) + let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env()) .map_err(|e| anyhow::anyhow!("pool error: {}", e))?; let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get()) diff --git a/src/config/database.rs b/src/config/database.rs index 0a580f91..44abc09b 100644 --- a/src/config/database.rs +++ b/src/config/database.rs @@ -40,6 +40,48 @@ impl std::str::FromStr for DatabaseBackend { } } +/// PostgreSQL SSL/TLS mode, matching libpq semantics for the common cases. +/// +/// Default is `Prefer`: attempt TLS, fall back to plaintext. This is the +/// safest non-breaking default — local Postgres without TLS keeps working +/// while managed providers (Neon, Supabase, RDS) automatically get TLS. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SslMode { + /// Never use TLS (equivalent to libpq `sslmode=disable`). + Disable, + /// Try TLS first; fall back to plaintext on failure (default). + #[default] + Prefer, + /// Require TLS; fail if the server does not support it. + Require, +} + +impl std::fmt::Display for SslMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disable => write!(f, "disable"), + Self::Prefer => write!(f, "prefer"), + Self::Require => write!(f, "require"), + } + } +} + +impl std::str::FromStr for SslMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "disable" => Ok(Self::Disable), + "prefer" => Ok(Self::Prefer), + "require" => Ok(Self::Require), + _ => Err(format!( + "invalid DATABASE_SSLMODE '{}', expected 'disable', 'prefer', or 'require'", + s + )), + } + } +} + /// Database configuration. #[derive(Debug, Clone)] pub struct DatabaseConfig { @@ -49,6 +91,8 @@ pub struct DatabaseConfig { // -- PostgreSQL fields -- pub url: SecretString, pub pool_size: usize, + /// TLS mode for PostgreSQL connections (default: Prefer). + pub ssl_mode: SslMode, // -- libSQL fields -- /// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db). @@ -88,6 +132,15 @@ impl DatabaseConfig { let pool_size = parse_optional_env("DATABASE_POOL_SIZE", 10)?; + let ssl_mode: SslMode = if let Some(s) = optional_env("DATABASE_SSLMODE")? { + s.parse().map_err(|e| ConfigError::InvalidValue { + key: "DATABASE_SSLMODE".to_string(), + message: e, + })? + } else { + SslMode::default() + }; + let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| { if backend == DatabaseBackend::LibSql { Some(default_libsql_path()) @@ -110,6 +163,7 @@ impl DatabaseConfig { backend, url: SecretString::from(url), pool_size, + ssl_mode, libsql_path, libsql_url, libsql_auth_token, @@ -122,7 +176,52 @@ impl DatabaseConfig { } } +impl SslMode { + /// Read from `DATABASE_SSLMODE` env var, defaulting to `Prefer`. + /// + /// Silently falls back to `Prefer` on missing or unparseable values. + /// Used by lightweight CLI tools (status, doctor) that don't run the + /// full config pipeline. + pub fn from_env() -> Self { + std::env::var("DATABASE_SSLMODE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_default() + } +} + /// Default libSQL database path (~/.ironclaw/ironclaw.db). pub fn default_libsql_path() -> PathBuf { ironclaw_base_dir().join("ironclaw.db") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssl_mode_default_is_prefer() { + assert_eq!(SslMode::default(), SslMode::Prefer); + } + + #[test] + fn ssl_mode_parse_roundtrip() { + for mode in [SslMode::Disable, SslMode::Prefer, SslMode::Require] { + let s = mode.to_string(); + let parsed: SslMode = s.parse().expect("should parse"); + assert_eq!(parsed, mode); + } + } + + #[test] + fn ssl_mode_parse_case_insensitive() { + assert_eq!("DISABLE".parse::().unwrap(), SslMode::Disable); + assert_eq!("Prefer".parse::().unwrap(), SslMode::Prefer); + assert_eq!("REQUIRE".parse::().unwrap(), SslMode::Require); + } + + #[test] + fn ssl_mode_parse_invalid() { + assert!("invalid".parse::().is_err()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index a15dc505..a89edcf4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -32,7 +32,7 @@ use crate::settings::Settings; pub use self::agent::AgentConfig; pub use self::builder::BuilderModeConfig; pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig}; -pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path}; +pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; pub use self::hygiene::HygieneConfig; diff --git a/src/db/mod.rs b/src/db/mod.rs index 7a6b8941..ee94f3ef 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -12,6 +12,9 @@ #[cfg(feature = "postgres")] pub mod postgres; +#[cfg(feature = "postgres")] +pub mod tls; + #[cfg(feature = "libsql")] pub mod libsql; diff --git a/src/db/tls.rs b/src/db/tls.rs new file mode 100644 index 00000000..e612704f --- /dev/null +++ b/src/db/tls.rs @@ -0,0 +1,86 @@ +//! TLS connector factory for PostgreSQL connections. +//! +//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector +//! based on the configured [`SslMode`]. Uses `rustls` with system root +//! certificates — the same TLS stack that `reqwest` already uses for HTTP. + +use deadpool_postgres::{Pool, Runtime}; +use tokio_postgres::NoTls; +use tokio_postgres_rustls::MakeRustlsConnect; + +use crate::config::SslMode; + +/// Build a rustls-based TLS connector using the platform's root certificate store. +fn make_rustls_connector() -> MakeRustlsConnect { + let mut root_store = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for e in &native.errors { + tracing::warn!("error loading system root certs: {e}"); + } + for cert in native.certs { + if let Err(e) = root_store.add(cert) { + tracing::warn!("skipping invalid system root cert: {e}"); + } + } + if root_store.is_empty() { + tracing::error!("no system root certificates found -- TLS connections will fail"); + } + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + MakeRustlsConnect::new(config) +} + +/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector. +/// +/// - `Disable` → plain TCP (no TLS) +/// - `Prefer` / `Require` → rustls with system root certificates +/// +/// **Note:** `Prefer` and `Require` currently behave identically — both +/// provide a TLS connector and will fail if the server rejects the TLS +/// handshake. True `prefer` semantics (retry without TLS on failure) +/// would require reconnection logic that tokio-postgres does not provide +/// out of the box. The three-variant enum is kept for forward-compatibility +/// and familiarity with libpq's `sslmode` parameter. +pub fn create_pool( + config: &deadpool_postgres::Config, + ssl_mode: SslMode, +) -> Result { + match ssl_mode { + SslMode::Disable => config.create_pool(Some(Runtime::Tokio1), NoTls), + SslMode::Prefer | SslMode::Require => { + let tls = make_rustls_connector(); + config.create_pool(Some(Runtime::Tokio1), tls) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_pool_disable_mode() { + let mut config = deadpool_postgres::Config::new(); + config.url = Some("postgres://localhost/test".to_string()); + // Should succeed — pool is created lazily, no actual connection needed. + let pool = create_pool(&config, SslMode::Disable); + assert!(pool.is_ok()); + } + + #[test] + fn create_pool_prefer_mode() { + let mut config = deadpool_postgres::Config::new(); + config.url = Some("postgres://localhost/test".to_string()); + let pool = create_pool(&config, SslMode::Prefer); + assert!(pool.is_ok()); + } + + #[test] + fn create_pool_require_mode() { + let mut config = deadpool_postgres::Config::new(); + config.url = Some("postgres://localhost/test".to_string()); + let pool = create_pool(&config, SslMode::Require); + assert!(pool.is_ok()); + } +} diff --git a/src/history/store.rs b/src/history/store.rs index 592c876e..2e5dc0c1 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -2,10 +2,8 @@ use chrono::{DateTime, Utc}; #[cfg(feature = "postgres")] -use deadpool_postgres::{Config, Pool, Runtime}; +use deadpool_postgres::{Config, Pool}; use rust_decimal::Decimal; -#[cfg(feature = "postgres")] -use tokio_postgres::NoTls; use uuid::Uuid; #[cfg(feature = "postgres")] @@ -50,8 +48,7 @@ impl Store { ..Default::default() }); - let pool = cfg - .create_pool(Some(Runtime::Tokio1), NoTls) + let pool = crate::db::tls::create_pool(&cfg, config.ssl_mode) .map_err(|e| DatabaseError::Pool(e.to_string()))?; // Test connection diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 58647a86..8b2f9014 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -15,10 +15,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; #[cfg(feature = "postgres")] -use deadpool_postgres::{Config as PoolConfig, Runtime}; +use deadpool_postgres::Config as PoolConfig; use secrecy::{ExposeSecret, SecretString}; -#[cfg(feature = "postgres")] -use tokio_postgres::NoTls; use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::{ @@ -556,8 +554,7 @@ impl SetupWizard { ..Default::default() }); - let pool = cfg - .create_pool(Some(Runtime::Tokio1), NoTls) + let pool = crate::db::tls::create_pool(&cfg, crate::config::SslMode::from_env()) .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; let client = pool