Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions rust-api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub struct DatabaseConfig {
pub name: String,
pub max_connections: u32,
pub min_connections: u32,
pub ssl_mode: Option<String>,
pub ssl_root_cert: Option<String>,
pub ssl_client_cert: Option<String>,
pub ssl_client_key: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -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()) {
Expand Down
137 changes: 123 additions & 14 deletions rust-api/src/database.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,6 +53,126 @@ pub struct ScheduledBackupRow {
pub last_run: Option<DateTime<Utc>>,
}

fn postgres_connect_options(db: &DatabaseConfig) -> AppResult<PgConnectOptions> {
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>`.
.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::<PgSslMode>().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<Self> {
let db = &config.database;
Expand All @@ -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>`.
.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)
Expand Down