From 45e79dd98a4408ffb21d7ec380cc032b79db6362 Mon Sep 17 00:00:00 2001 From: Hayden Young Date: Sat, 22 Aug 2026 19:39:01 +0100 Subject: [PATCH] feat: support PostgreSQL client certificates The SQLx connection builder only applied the core DB_* settings, so PinePods-specific configuration could not pass client certificate paths required by PostgreSQL mTLS authentication. --- README.md | 20 +++ .../docker-compose.yml | 7 + rust-api/src/config.rs | 8 + rust-api/src/database.rs | 137 ++++++++++++++++-- 4 files changed, 158 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c62d6231..d8caa119 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,26 @@ services: - valkey ``` +If an external PostgreSQL server requires a TLS client certificate, mount the +certificate files into the PinePods container and add these optional settings: + +```yaml +services: + pinepods: + environment: + DB_SSL_MODE: verify-full + DB_SSL_ROOT_CERT: /run/secrets/postgresql/ca.crt + DB_SSL_CLIENT_CERT: /run/secrets/postgresql/client.crt + DB_SSL_CLIENT_KEY: /run/secrets/postgresql/client.key + volumes: + - /home/user/pinepods/postgresql-certs:/run/secrets/postgresql:ro +``` + +`DB_SSL_CLIENT_CERT` and `DB_SSL_CLIENT_KEY` must be set together. `DB_SSL_MODE` +accepts `disable`, `allow`, `prefer`, `require`, `verify-ca`, or `verify-full`; +the default remains `prefer`. + + Then start it: ```bash diff --git a/deployment/docker/compose-files/docker-compose-postgres/docker-compose.yml b/deployment/docker/compose-files/docker-compose-postgres/docker-compose.yml index 2e58311b..3f9abce7 100644 --- a/deployment/docker/compose-files/docker-compose-postgres/docker-compose.yml +++ b/deployment/docker/compose-files/docker-compose-postgres/docker-compose.yml @@ -31,6 +31,11 @@ services: DB_USER: postgres DB_PASSWORD: myS3curepass DB_NAME: pinepods_database + # Optional PostgreSQL TLS/mTLS settings. Certificate paths are inside this container. + # DB_SSL_MODE: verify-full + # DB_SSL_ROOT_CERT: /run/secrets/postgresql/ca.crt + # DB_SSL_CLIENT_CERT: /run/secrets/postgresql/client.crt + # DB_SSL_CLIENT_KEY: /run/secrets/postgresql/client.key # Valkey Settings VALKEY_HOST: valkey VALKEY_PORT: 6379 @@ -49,6 +54,8 @@ services: # Mount the download and backup locations on the server - /home/user/pinepods/downloads:/opt/pinepods/downloads - /home/user/pinepods/backups:/opt/pinepods/backups + # Mount PostgreSQL certificates when using the optional TLS settings above + # - /home/user/pinepods/postgresql-certs:/run/secrets/postgresql:ro # Mount local media files to use the Add Local Podcast feature (optional) - /home/user/pinepods/local-media:/opt/pinepods/local-media # Timezone volumes, HIGHLY optional. Read the timezone notes below diff --git a/rust-api/src/config.rs b/rust-api/src/config.rs index 480a06d2..2ae3c5a6 100644 --- a/rust-api/src/config.rs +++ b/rust-api/src/config.rs @@ -29,6 +29,10 @@ pub struct DatabaseConfig { pub name: String, pub max_connections: u32, pub min_connections: u32, + pub ssl_mode: Option, + pub ssl_root_cert: Option, + pub ssl_client_cert: Option, + pub ssl_client_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -210,6 +214,10 @@ impl Config { name: env::var("DB_NAME").unwrap(), max_connections: 32, min_connections: 1, + ssl_mode: env::var("DB_SSL_MODE").ok(), + ssl_root_cert: env::var("DB_SSL_ROOT_CERT").ok(), + ssl_client_cert: env::var("DB_SSL_CLIENT_CERT").ok(), + ssl_client_key: env::var("DB_SSL_CLIENT_KEY").ok(), }; let redis = if let Some(url) = env::var("VALKEY_URL").ok().or_else(|| env::var("REDIS_URL").ok()) { diff --git a/rust-api/src/database.rs b/rust-api/src/database.rs index 30a7de4c..1fddf255 100644 --- a/rust-api/src/database.rs +++ b/rust-api/src/database.rs @@ -1,6 +1,7 @@ use sqlx::{MySql, Pool, Postgres, Row}; +use sqlx::postgres::{PgConnectOptions, PgSslMode}; use std::time::Duration; -use crate::{config::{Config, OIDCConfig}, error::{AppError, AppResult}}; +use crate::{config::{Config, DatabaseConfig, OIDCConfig}, error::{AppError, AppResult}}; use chrono::{DateTime, Utc}; use chrono_tz::Tz; use std::collections::HashMap; @@ -52,6 +53,126 @@ pub struct ScheduledBackupRow { pub last_run: Option>, } +fn postgres_connect_options(db: &DatabaseConfig) -> AppResult { + let mut options = PgConnectOptions::new() + .username(&db.username) + .password(&db.password) + .database(&db.name) + // Port is still relevant for sockets: PG names the socket + // file `.s.PGSQL.`. + .port(db.port); + + options = if db.host.starts_with('/') { + options.socket(&db.host) + } else { + options.host(&db.host) + }; + + if let Some(mode) = &db.ssl_mode { + let mode = mode.parse::().map_err(|_| { + AppError::Config(format!( + "Invalid DB_SSL_MODE '{mode}'. Expected disable, allow, prefer, require, verify-ca, or verify-full" + )) + })?; + options = options.ssl_mode(mode); + } + + if let Some(root_cert) = &db.ssl_root_cert { + options = options.ssl_root_cert(root_cert); + } + + match (&db.ssl_client_cert, &db.ssl_client_key) { + (Some(cert), Some(key)) => { + options = options + .ssl_client_cert(cert) + .ssl_client_key(key); + } + (None, None) => {} + _ => { + return Err(AppError::Config( + "DB_SSL_CLIENT_CERT and DB_SSL_CLIENT_KEY must be set together".to_string(), + )); + } + } + + Ok(options) +} + +#[cfg(test)] +mod postgres_connect_options_tests { + use super::postgres_connect_options; + use crate::config::DatabaseConfig; + use sqlx::ConnectOptions; + use std::collections::HashMap; + + fn database_config() -> DatabaseConfig { + DatabaseConfig { + db_type: "postgresql".to_string(), + host: "database.example.com".to_string(), + port: 5432, + username: "pinepods".to_string(), + password: "secret".to_string(), + name: "pinepods".to_string(), + max_connections: 32, + min_connections: 1, + ssl_mode: Some("verify-full".to_string()), + ssl_root_cert: Some("/certs/ca.crt".to_string()), + ssl_client_cert: Some("/certs/client.crt".to_string()), + ssl_client_key: Some("/certs/client.key".to_string()), + } + } + + #[test] + fn includes_postgres_tls_credentials() { + let url = postgres_connect_options(&database_config()) + .expect("PostgreSQL TLS options should be valid") + .to_url_lossy(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + + assert_eq!(query.get("sslmode").map(String::as_str), Some("verify-full")); + assert_eq!( + query.get("sslrootcert").map(String::as_str), + Some("file: /certs/ca.crt") + ); + assert_eq!( + query.get("sslcert").map(String::as_str), + Some("file: /certs/client.crt") + ); + assert_eq!( + query.get("sslkey").map(String::as_str), + Some("file: /certs/client.key") + ); + } + + #[test] + fn rejects_client_certificate_without_private_key() { + let mut config = database_config(); + config.ssl_client_key = None; + + let error = postgres_connect_options(&config) + .expect_err("a client certificate without its key must be rejected"); + + assert_eq!( + error.to_string(), + "Configuration error: DB_SSL_CLIENT_CERT and DB_SSL_CLIENT_KEY must be set together" + ); + } + + #[test] + fn rejects_invalid_ssl_mode() { + let mut config = database_config(); + config.ssl_mode = Some("sometimes".to_string()); + + let error = + postgres_connect_options(&config).expect_err("an invalid SSL mode must be rejected"); + + assert_eq!( + error.to_string(), + "Configuration error: Invalid DB_SSL_MODE 'sometimes'. Expected disable, allow, prefer, require, verify-ca, or verify-full" + ); + } +} + impl DatabasePool { pub async fn new(config: &Config) -> AppResult { let db = &config.database; @@ -61,19 +182,7 @@ impl DatabasePool { match db.db_type.as_str() { "postgresql" => { - let mut options = sqlx::postgres::PgConnectOptions::new() - .username(&db.username) - .password(&db.password) - .database(&db.name) - // Port is still relevant for sockets: PG names the socket - // file `.s.PGSQL.`. - .port(db.port); - options = if host_is_socket { - options.socket(&db.host) - } else { - options.host(&db.host) - }; - + let options = postgres_connect_options(db)?; let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(db.max_connections) .min_connections(db.min_connections)