Skip to content
Merged
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
782 changes: 781 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ for you and that production deployments set via the platzio helm chart:
for new chart artifacts.
* `platz-api` reads `OIDC_*` environment variables for OIDC config and
`ADMIN_EMAILS` as a space-delimited allow-list.
* All workers connect to PostgreSQL using the `PG*` variables (`PGHOST`,
`PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`). TLS for those connections —
including the `LISTEN`/`NOTIFY` event stream — is controlled by `PGSSLMODE`
(mirroring libpq), defaulting to `prefer`:
* `disable` — plaintext, no TLS.
* `prefer` *(default)* — use TLS if the server offers it, otherwise
plaintext; the server certificate is not verified.
* `require` — always use TLS; the certificate is not verified.
* `verify-full` — always use TLS and verify the certificate chain and
hostname against the system trust store, or against the CA bundle pointed
to by `PGSSLROOTCERT`.

## Crates Overview

Expand Down
11 changes: 11 additions & 0 deletions db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ prometheus = { workspace = true }
rust_decimal = { version = "1.42.0", default-features = false, features = [
"tokio-postgres",
] }
rustls = "0.23.40"
rustls-native-certs = "0.8.4"
rustls-pemfile = "2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
serde_with = "3.20.0"
strum = { version = "0.28.0", features = ["derive"] }
thiserror = "2.0.18"
tokio = "1.52.3"
tokio-postgres = "0.7.17"
tokio-postgres-rustls = "0.14.0"
tracing = "0.1.44"
url = "2.5.8"
utoipa = { version = "5.5.0", features = [
Expand All @@ -57,3 +61,10 @@ utoipa = { version = "5.5.0", features = [
"uuid",
] }
uuid = { version = "1.23.1", features = ["serde", "v4"] }

[dev-dependencies]
rcgen = "0.14.8"
tempfile = "3"
testcontainers = "0.27.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-postgres = "0.7"
59 changes: 59 additions & 0 deletions db/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const DB_POOL_CONNECTION_TIMEOUT_SECS: &str = "DB_POOL_CONNECTION_TIMEOUT_SECS";
const DB_POOL_IDLE_TIMEOUT_SECS: &str = "DB_POOL_IDLE_TIMEOUT_SECS";
const DB_POOL_MAX_LIFETIME_SECS: &str = "DB_POOL_MAX_LIFETIME_SECS";

const PGSSLMODE: &str = "PGSSLMODE";
const PGSSLROOTCERT: &str = "PGSSLROOTCERT";

#[derive(Debug, Clone)]
pub struct DbPoolOptions {
pub max_size: u32,
Expand Down Expand Up @@ -43,6 +46,62 @@ pub fn database_url() -> String {
format!("postgres://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}")
}

/// How the backend negotiates TLS when connecting to PostgreSQL.
///
/// Mirrors the relevant subset of libpq's `sslmode` values. Because Platz
/// assembles the connection itself (it does not link libpq), this is the
/// authoritative knob — `PGSSLMODE` and `PGSSLROOTCERT` are read here and
/// applied to both the connection pool and the `LISTEN`/`NOTIFY` connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString)]
#[strum(ascii_case_insensitive)]
pub enum SslMode {
/// Never use TLS. The connection is plaintext (legacy behavior).
#[strum(serialize = "disable")]
Disable,
/// Try TLS first, fall back to plaintext if the server doesn't offer it.
/// The server certificate is not verified.
#[strum(serialize = "prefer")]
Prefer,
/// Require TLS, but do not verify the server certificate.
#[strum(serialize = "require")]
Require,
/// Require TLS and verify the server certificate chain against the
/// trusted CAs, including that the hostname matches the certificate.
#[strum(serialize = "verify-full", serialize = "verify_full")]
VerifyFull,
}

/// Resolved TLS settings for connecting to PostgreSQL, derived from the
/// `PGSSLMODE` and `PGSSLROOTCERT` environment variables.
#[derive(Debug, Clone)]
pub struct SslSettings {
pub mode: SslMode,
/// Path to a PEM-encoded CA bundle used to verify the server certificate
/// in `verify-full` mode. When `None`, the system trust store is used.
pub root_cert: Option<String>,
}

impl SslSettings {
/// Reads the TLS settings from the environment.
///
/// `PGSSLMODE` defaults to `prefer` (opportunistic TLS) so that existing
/// plaintext databases keep working while TLS-capable databases are used
/// encrypted without any extra configuration.
pub fn from_env() -> Result<Self, String> {
let mode = match env::var(PGSSLMODE) {
Ok(value) => value.parse().map_err(|_| {
format!(
"Invalid {PGSSLMODE} value {value:?}. \
Expected one of: disable, prefer, require, verify-full"
)
})?,
Err(_) => SslMode::Prefer,
};
let root_cert = env::var(PGSSLROOTCERT).ok().filter(|s| !s.is_empty());
Ok(Self { mode, root_cert })
}
}

pub fn db_pool_options() -> DbPoolOptions {
let defaults = DbPoolOptions::default();
DbPoolOptions {
Expand Down
6 changes: 6 additions & 0 deletions db/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ pub enum DbError {
#[error("Database pool startup error: {0}")]
Bb8PoolError(#[from] diesel_async::pooled_connection::PoolError),

#[error("Database TLS error: {0}")]
TlsError(#[from] crate::tls::TlsError),

#[error("Invalid database TLS configuration: {0}")]
SslConfigError(String),

#[error("Database was not initialized")]
DbNotInitialized,

Expand Down
105 changes: 76 additions & 29 deletions db/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
use crate::DbTable;
use crate::config::SslSettings;
use serde::{Deserialize, Serialize};
use std::{future::poll_fn, task::ready};
use tokio::{spawn, sync::broadcast, time};
use tokio_postgres::AsyncMessage;
use tokio::{
io::{AsyncRead, AsyncWrite},
spawn,
sync::broadcast,
task::JoinHandle,
time,
};
use tokio_postgres::{AsyncMessage, Connection, NoTls};
use tracing::{debug, error, trace};
use utoipa::ToSchema;
use uuid::Uuid;
Expand Down Expand Up @@ -72,6 +79,10 @@ pub enum DbEventsError {
PollError(tokio_postgres::Error),
#[error("Error running LISTEN query: {0}")]
ListenQueryFailed(tokio_postgres::Error),
#[error("Invalid database TLS configuration: {0}")]
SslConfigError(String),
#[error("Database TLS error: {0}")]
TlsError(crate::tls::TlsError),
}

impl DbEventsError {
Expand All @@ -82,6 +93,9 @@ impl DbEventsError {
Self::ConnectError(_) => true,
Self::PollError(_) => true,
Self::ListenQueryFailed(_) => false,
// Misconfiguration won't fix itself on retry.
Self::SslConfigError(_) => false,
Self::TlsError(_) => false,
}
}
}
Expand Down Expand Up @@ -109,34 +123,31 @@ impl DbEventBroadcast {

async fn listen_for_notifications(&self, channel_name: &str) -> Result<(), DbEventsError> {
let events_tx = self.tx.clone();
let (client, mut connection) =
tokio_postgres::connect(&crate::config::database_url(), tokio_postgres::NoTls)
.await
.map_err(DbEventsError::ConnectError)?;

let events_task = spawn(poll_fn(move |cx| {
loop {
while let Some(message) = ready!(
connection
.poll_message(cx)
.map_err(DbEventsError::PollError)?
) {
match message {
AsyncMessage::Notice(notice) => {
trace!("Database notice: {notice:?}");
}
AsyncMessage::Notification(notification) => {
let event: DbEvent = serde_json::from_str(notification.payload())
.map_err(DbEventsError::EventParseError)?;
events_tx.send(event).ok();
}
other => {
trace!("Got unknown message from Postgres: {other:?}");
}
}
let url = crate::config::database_url();
let ssl = SslSettings::from_env().map_err(DbEventsError::SslConfigError)?;

// Establish the dedicated LISTEN/NOTIFY connection using the same TLS
// settings as the connection pool. With TLS disabled we keep the
// original plaintext (`NoTls`) path.
let (client, events_task) =
match crate::tls::build_connector(&ssl).map_err(DbEventsError::TlsError)? {
None => {
let (client, connection) = tokio_postgres::connect(&url, NoTls)
.await
.map_err(DbEventsError::ConnectError)?;
(client, spawn_event_pump(connection, events_tx))
}
}
}));
Some(connector) => {
let mut config: tokio_postgres::Config =
url.parse().map_err(DbEventsError::ConnectError)?;
config.ssl_mode(crate::tls::pg_ssl_mode(ssl.mode));
let (client, connection) = config
.connect(connector)
.await
.map_err(DbEventsError::ConnectError)?;
(client, spawn_event_pump(connection, events_tx))
}
};

client
.execute(&format!("LISTEN {channel_name}"), &[])
Expand All @@ -146,3 +157,39 @@ impl DbEventBroadcast {
events_task.await?
}
}

/// Pumps `LISTEN`/`NOTIFY` messages off a Postgres connection and rebroadcasts
/// them as [`DbEvent`]s. Generic over the connection's stream type so it works
/// with both the plaintext (`NoTls`) and TLS-wrapped connections.
fn spawn_event_pump<S, T>(
mut connection: Connection<S, T>,
events_tx: DbEventSender,
) -> JoinHandle<Result<(), DbEventsError>>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
T: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
spawn(poll_fn(move |cx| {
loop {
while let Some(message) = ready!(
connection
.poll_message(cx)
.map_err(DbEventsError::PollError)?
) {
match message {
AsyncMessage::Notice(notice) => {
trace!("Database notice: {notice:?}");
}
AsyncMessage::Notification(notification) => {
let event: DbEvent = serde_json::from_str(notification.payload())
.map_err(DbEventsError::EventParseError)?;
events_tx.send(event).ok();
}
other => {
trace!("Got unknown message from Postgres: {other:?}");
}
}
}
}
}))
}
24 changes: 21 additions & 3 deletions db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ mod identity;
pub mod json_diff;
pub mod schema;
mod stats;
pub mod tls;
mod ui_collection;

use crate::config::{DbPoolOptions, database_url, db_pool_options};
pub use config::{SslMode, SslSettings};
pub use db_table::*;
use diesel_async::{
AsyncPgConnection,
async_connection_wrapper::AsyncConnectionWrapper,
pooled_connection::{
AsyncDieselConnectionManager,
AsyncDieselConnectionManager, ManagerConfig,
bb8::{Pool, PooledConnection},
},
};
Expand Down Expand Up @@ -47,8 +49,24 @@ pub struct Db {
impl Db {
async fn new(pool_options: DbPoolOptions) -> DbResult<Self> {
let connection_url = database_url();
info!("Connecting to {connection_url}");
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(connection_url);
let ssl = SslSettings::from_env().map_err(errors::DbError::SslConfigError)?;
info!("Connecting to {connection_url} (sslmode={:?})", ssl.mode);

// Wire the TLS connector into every pooled connection via a custom
// setup callback, so the pool negotiates TLS exactly like the
// LISTEN/NOTIFY connection in `events.rs`.
let connector = tls::build_connector(&ssl)?;
let mode = ssl.mode;
let mut manager_config = ManagerConfig::default();
manager_config.custom_setup = Box::new(move |url| {
let connector = connector.clone();
let url = url.to_string();
Box::pin(async move { tls::establish_connection(&url, connector, mode).await })
});
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(
connection_url,
manager_config,
);
let pool = Pool::builder()
.max_size(pool_options.max_size)
.min_idle(pool_options.min_idle)
Expand Down
Loading
Loading