From 08afa1e495940200b373952442e1d37218659a42 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 20 Oct 2026 13:09:50 +0200 Subject: [PATCH] feat: select and save CA bundles per PostgreSQL connection --- src/connection.rs | 161 +++++++++++++++++++++++++++++++++++++++++----- src/db.rs | 28 ++++---- src/main.rs | 45 +++++++++---- 3 files changed, 188 insertions(+), 46 deletions(-) diff --git a/src/connection.rs b/src/connection.rs index 5226d64..137f6d8 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -435,12 +435,14 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Esc Leave Insert first, then return to explorer\n\n\ Connection / password windows\n\ Tab / Shift-Tab Change fields\n\ - Arrows Choose provider; Left/Right changes TLS\n\ + Arrows Choose provider; Left/Right changes TLS or CA bundle\n\ Enter Edit provider / save settings / connect\n\ Ctrl-U / paste Clear / enter text in a focused field\n\ Esc Cancel and discard edits\n\ Optional Name sets the sidebar label; blank uses the default.\n\ Connect saves settings only on success; e edits save without connecting.\n\ + CA bundles come from .pem/.crt files in the dbdash config directory.\n\ + Choosing a bundle requires TLS; choose System trust to unlock TLS mode.\n\ Passwords are never saved.", ) .wrap(Wrap { trim: false }); @@ -627,7 +629,13 @@ impl Profiles { "ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''", )?; } - let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name FROM connections ORDER BY id")? + if !storage + .prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'ca_bundle'")? + .exists([])? + { + storage.execute_batch("ALTER TABLE connections ADD COLUMN ca_bundle TEXT")?; + } + let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name, ca_bundle FROM connections ORDER BY id")? .query_map([], |row| { let mut form = Form { id: Some(row.get(7)?), @@ -635,6 +643,7 @@ impl Profiles { provider: usize::from(row.get::<_, u8>(0)?), tls: usize::from(row.get::<_, u8>(5)?), path: Input::from(row.get::<_, String>(6)?.as_str()), + ca_bundle: row.get::<_, Option>(9)?.map(PathBuf::from), ..Form::default() }; for index in 0..4 { @@ -660,6 +669,7 @@ impl Profiles { } }), tls: form.tls, + ca_bundle: (form.provider == 0).then(|| form.ca_bundle.clone()).flatten(), path: form.path.clone(), error: None, reconnect: false, @@ -678,12 +688,12 @@ impl Profiles { saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); } self.storage.execute( - "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name, ca_bundle) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, host = excluded.host, port = excluded.port, database_name = excluded.database_name, username = excluded.username, - tls = excluded.tls, path = excluded.path, name = excluded.name", + tls = excluded.tls, path = excluded.path, name = excluded.name, ca_bundle = excluded.ca_bundle", rusqlite::params![ saved.provider as u8, saved.postgres[0].value, @@ -693,7 +703,8 @@ impl Profiles { saved.tls as u8, saved.path.value, saved.id, - saved.name.value + saved.name.value, + saved.ca_bundle.as_ref().map(|path| path.to_str().ok_or("CA bundle path must be UTF-8")).transpose()? ], )?; saved.id.get_or_insert(self.storage.last_insert_rowid()); @@ -723,6 +734,7 @@ struct Form { focus: usize, postgres: [Input; 5], tls: usize, + ca_bundle: Option, path: Input, error: Option, reconnect: bool, @@ -736,6 +748,7 @@ impl Default for Form { provider: 0, focus: 0, tls: 0, + ca_bundle: None, postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from), path: Input::default(), error: None, @@ -749,10 +762,41 @@ impl Form { if self.provider == 1 { 2 } else { - 6 + usize::from(self.reconnect) + 7 + usize::from(self.reconnect) } } + fn ca_focus(&self) -> usize { + 6 + usize::from(self.reconnect) + } + + fn select_ca_bundle(&mut self, directory: &Path, forward: bool) -> Result<()> { + let mut bundles = Vec::new(); + for entry in std::fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_file() + && path.extension().is_some_and(|ext| { + ext.eq_ignore_ascii_case("pem") || ext.eq_ignore_ascii_case("crt") + }) + { + bundles.push(path); + } + } + bundles.sort(); + let mut choices = vec![None]; + choices.extend(bundles.into_iter().map(Some)); + if !choices.contains(&self.ca_bundle) { + choices.push(self.ca_bundle.clone()); + } + let current = choices.iter().position(|path| path == &self.ca_bundle).unwrap(); + let next = (current + if forward { 1 } else { choices.len() - 1 }) % choices.len(); + self.ca_bundle = choices.swap_remove(next); + if self.ca_bundle.is_some() { + self.tls = 0; + } + Ok(()) + } + fn connect(&self) -> Result { if self.provider == 1 { if self.path.value.is_empty() { @@ -760,10 +804,13 @@ impl Form { } return Database::open_sqlite(Path::new(&self.path.value)); } - Database::connect_postgres(self.postgres_config()?) + Database::connect_postgres(self.postgres_config()?, self.ca_bundle.as_deref()) } fn postgres_config(&self) -> Result { + if self.ca_bundle.is_some() && self.tls != 0 { + return Err("A CA bundle requires Require TLS; plaintext fallback is not allowed.".into()); + } let [host, port, database, user, password] = &self.postgres; if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { return Err("PostgreSQL requires a host, database, and user.".into()); @@ -824,6 +871,7 @@ impl Form { } fn edit(&mut self, key: KeyEvent) { + self.error = None; let count = self.name_focus() + 1; match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % count, @@ -834,7 +882,19 @@ impl Form { KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) => { - self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3; + if self.ca_bundle.is_none() { + self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3; + } + } + KeyCode::Left | KeyCode::Right + if self.provider == 0 && self.focus == self.ca_focus() => + { + let result = config_path().and_then(|path| { + self.select_ca_bundle(path.parent().unwrap(), key.code == KeyCode::Right) + }); + if let Err(error) = result { + self.error = Some(error_text(error.as_ref())); + } } _ => { if let Some(input) = self.input_mut() { @@ -842,7 +902,6 @@ impl Form { } } } - self.error = None; } fn label(&self) -> String { @@ -878,12 +937,13 @@ impl Form { ) } else { format!( - "Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nAccess: READ ONLY", + "Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nCA bundle: {}\nAccess: READ ONLY", crate::db::display_text(&self.postgres[0].value), crate::db::display_text(&self.postgres[1].value), crate::db::display_text(&self.postgres[2].value), crate::db::display_text(&self.postgres[3].value), ["Require", "Prefer", "Disable"][self.tls], + self.ca_bundle.as_ref().map_or_else(|| "System trust".into(), |path| crate::db::display_text(&path.to_string_lossy())), ) } } @@ -916,7 +976,7 @@ impl Form { } fn draw(&self, frame: &mut Frame, screen: Rect) { - let minimum_height = if self.provider == 0 { 23 } else { 19 }; + let minimum_height = if self.provider == 0 { 26 } else { 19 }; if screen.width < 74 || screen.height < minimum_height { frame.render_widget(Clear, screen); frame.render_widget( @@ -1009,6 +1069,7 @@ impl Form { Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), + Constraint::Length(3), Constraint::Min(0), ]) .split(details); @@ -1050,6 +1111,22 @@ impl Form { ), pairs[2][usize::from(self.reconnect)], ); + let ca_label = self.ca_bundle.as_ref().map_or_else( + || "System trust".into(), + |path| crate::db::display_text(&path.file_name().unwrap_or(path.as_os_str()).to_string_lossy()), + ); + frame.render_widget( + Paragraph::new(ca_label).block( + Block::bordered().title(" CA bundle · ←/→ choose ").border_style( + Style::default().fg(if self.focus == self.ca_focus() { + Color::Cyan + } else { + Color::DarkGray + }), + ), + ), + rows[3], + ); let tls_notice = match self.tls { 0 => "TLS required; certificates and hostnames are verified.", 1 => "TLS preferred; the server may fall back to unencrypted traffic.", @@ -1058,10 +1135,10 @@ impl Form { frame.render_widget( Paragraph::new(vec![ Line::raw(tls_notice), - Line::raw("Passwords are requested on connect, never saved."), + Line::raw("CA bundles: .pem/.crt in the dbdash config directory."), ]) .wrap(Wrap { trim: false }), - rows[3], + rows[4], ); } let message = self.error.as_deref().unwrap_or(if self.reconnect { @@ -1080,9 +1157,9 @@ impl Form { status, ); frame.render_widget(Paragraph::new(if self.reconnect { - " Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" + " Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS/CA · Ctrl-U clear · Esc cancel · Ctrl-C quit" } else { - " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" + " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS/CA · Ctrl-U clear · Esc cancel · Ctrl-C quit" }).style(Style::default().fg(Color::DarkGray)), help); } } @@ -1375,6 +1452,55 @@ impl Input { mod tests { use super::*; + #[test] + fn ca_selector_persists_per_profile_and_keeps_missing_selections_fail_closed() -> Result<()> { + let directory = tempfile::tempdir()?; + let first = directory.path().join("a.pem"); + let second = directory.path().join("b.CRT"); + std::fs::write(&first, "")?; + std::fs::write(&second, "")?; + std::fs::write(directory.path().join("private.key"), "")?; + std::fs::create_dir(directory.path().join("not-a-file.pem"))?; + let config = directory.path().join("connections.sqlite3"); + let mut profiles = Profiles::open(&config)?; + let mut form = Form::default(); + form.postgres[3] = Input::from("reader"); + form.tls = 1; + form.select_ca_bundle(directory.path(), true)?; + assert_eq!(form.ca_bundle.as_deref(), Some(first.as_path())); + assert_eq!(form.postgres_config()?.get_ssl_mode(), SslMode::Require); + for reconnect in [false, true] { + form.reconnect = reconnect; + form.focus = form.ca_focus() - 1; + form.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(form.tls, 0); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(form.input().is_none()); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(form.input().is_some()); + } + profiles.save(&form)?; + form.select_ca_bundle(directory.path(), true)?; + assert_eq!(form.ca_bundle.as_deref(), Some(second.as_path())); + profiles.save(&form)?; + form.select_ca_bundle(directory.path(), true)?; + assert!(form.ca_bundle.is_none()); + profiles.save(&form)?; + form.select_ca_bundle(directory.path(), false)?; + assert_eq!(form.ca_bundle.as_deref(), Some(second.as_path())); + drop(profiles); + std::fs::remove_file(&first)?; + let profiles = Profiles::open(&config)?; + assert_eq!(profiles.entries[0].ca_bundle.as_deref(), Some(first.as_path())); + assert_eq!(profiles.entries[1].ca_bundle.as_deref(), Some(second.as_path())); + assert!(profiles.entries[2].ca_bundle.is_none()); + assert!(profiles.entries[0].connect().is_err()); + let mut missing = profiles.entries[0].clone(); + missing.tls = 1; + assert!(missing.postgres_config().is_err()); + Ok(()) + } + #[test] fn clearing_passwords_wipes_the_live_buffer_and_resets_unicode_cursor() { for cancel in [true, false] { @@ -1706,7 +1832,7 @@ mod tests { ..Form::default() }; form.postgres[4] = Input::from("test-secret-é"); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?; + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 28))?; terminal.draw(|frame| { let area = frame.area(); form.draw(frame, area); @@ -1791,6 +1917,7 @@ mod tests { assert!(form.input().is_none()); form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.input_mut().unwrap().paste("Warehouse"); for value in ["db.example", "5440", "warehouse", "inspector"] { form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); diff --git a/src/db.rs b/src/db.rs index 33454cc..ae8e060 100644 --- a/src/db.rs +++ b/src/db.rs @@ -53,19 +53,15 @@ pub struct QueryResult { } impl Database { - pub fn connect(connection: &str) -> Result { - Self::connect_postgres(connection.parse()?) + pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result { + Self::connect_postgres(connection.parse()?, root_cert) } - pub fn connect_postgres(mut config: Config) -> Result { + pub fn connect_postgres(mut config: Config, root_cert: Option<&Path>) -> Result { if config.get_connect_timeout().is_none() { config.connect_timeout(Duration::from_secs(5)); } - let root_cert = std::env::var_os("PGSSLROOTCERT"); - let tls = MakeTlsConnector::new(postgres_tls( - config.get_ssl_mode(), - root_cert.as_deref().map(Path::new), - )?); + let tls = MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), root_cert)?); let mut client = config.connect(tls)?; // Views can invoke functions: enforce read-only on the server, not just in the UI. client.batch_execute( @@ -284,14 +280,14 @@ fn postgres_tls(mode: SslMode, root_cert: Option<&Path>) -> Result let mut tls = TlsConnector::builder(); if let Some(path) = root_cert { if mode != SslMode::Require { - return Err("PGSSLROOTCERT requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); + return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); } let pem = std::fs::read(path) - .map_err(|error| format!("Cannot read PGSSLROOTCERT CA file: {error}"))?; + .map_err(|error| format!("Cannot read CA bundle: {error}"))?; let certificates = Certificate::stack_from_pem(&pem) - .map_err(|error| format!("Invalid PGSSLROOTCERT PEM CA bundle: {error}"))?; + .map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; if certificates.is_empty() { - return Err("PGSSLROOTCERT must contain at least one PEM CA certificate.".into()); + return Err("CA bundle must contain at least one PEM CA certificate.".into()); } // An explicit bundle replaces system roots; certificate and hostname checks stay enabled. tls.disable_built_in_roots(true); @@ -333,16 +329,14 @@ mod tests { fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> { let bundle = tempfile::NamedTempFile::new()?; for mode in [SslMode::Prefer, SslMode::Disable] { - let error = postgres_tls(mode, Some(bundle.path())).unwrap_err(); - assert!(error.to_string().contains("sslmode=require")); + assert!(postgres_tls(mode, Some(bundle.path())).is_err()); } for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] { std::fs::write(bundle.path(), contents)?; assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err()); } let missing = bundle.path().with_extension("missing"); - let error = postgres_tls(SslMode::Require, Some(&missing)).unwrap_err(); - assert!(error.to_string().contains("Cannot read PGSSLROOTCERT")); + assert!(postgres_tls(SslMode::Require, Some(&missing)).is_err()); Ok(()) } @@ -396,7 +390,7 @@ mod tests { CREATE VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\"; CREATE TABLE {schema}.empty_table (id integer);" ))?; - let mut db = Database::connect(&connection)?; + let mut db = Database::connect(&connection, None)?; let relations = db.relations()?; let table = relations .iter() diff --git a/src/main.rs b/src/main.rs index ca6c048..2d811af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection Usage: dbdash [POSTGRES_CONNECTION_STRING] + dbdash --ca-bundle PATH POSTGRES_CONNECTION_STRING dbdash --sqlite PATH dbdash --help @@ -33,18 +34,24 @@ PostgreSQL URLs and libpq-style key=value strings are accepted. Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=disable only for trusted local connections. -For private CAs (including Amazon RDS), set PGSSLROOTCERT to a PEM CA bundle: - PGSSLROOTCERT="$HOME/.config/dbdash/rds-us-east-1.pem" cargo run -This applies to saved and CLI PostgreSQL connections. Choose Require TLS in the -connection window, or sslmode=require on the CLI; Prefer/Disable are rejected. -Only the supplied bundle is trusted while set; macOS/system trust is unchanged. -Certificates and hostnames are verified before authentication; no plaintext fallback. -Missing, empty, or invalid CA files block the connection. Use a bundle obtained -from the trusted issuer, not the certificate presented by an unverified server. +For private CAs (including Amazon RDS), put PEM bundles (.pem or .crt) in +$XDG_CONFIG_HOME/dbdash (default: ~/.config/dbdash), then launch normally: + cargo run +In the connection window, Tab to CA bundle and use Left/Right to choose a file +or System trust. The selection is saved per connection and shown in database info. +Selecting a bundle forces Require TLS; choose System trust before changing TLS mode. +The selector rescans on each change; missing saved files are not silently replaced. +For CLI PostgreSQL connections, use --ca-bundle PATH before the connection string +and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected +instead of silently losing their custom CA verification. +Only the selected bundle is trusted for that connection; system trust is unchanged. +Certificates and hostnames are verified before authentication; no plaintext fallback +with a CA bundle. Missing, empty, or invalid selected files block connection. +Use a bundle from the trusted issuer, not an unverified server's certificate. For RDS us-east-1, download AWS's official regional bundle: https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem Enter passwords in the in-app field; putting them in CLI arguments exposes them. -The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×25/74×21. +The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×28/74×21. Navigation: ? Show keybindings in Normal mode (Esc/Enter/? closes) @@ -91,7 +98,7 @@ Navigation: without clearing the query text. Query text is not saved across connections. q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT) New database window: Up/Down chooses a provider; Enter starts editing. - Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused. + Tab/Shift-Tab changes fields; Left/Right changes TLS or the CA bundle when focused. Enter saves without connecting, including when the database is offline. Optional Name controls the sidebar label; leave it blank for the default label. e on a saved database or its child edits the configuration without connecting. @@ -100,7 +107,7 @@ Navigation: Database files are never deleted. Removing the active profile keeps its session open. Select a saved database and press Enter to connect. PostgreSQL opens an editable connection window, initially focused on the password. Tab/Shift-Tab changes fields; - Left/Right changes TLS when focused. Enter connects and updates that saved entry + Left/Right changes TLS or the CA bundle when focused. Enter connects and updates that saved entry only on success; failures keep the previous settings and connection. Esc discards edits. Passwords are masked, never saved, and clear on success or cancellation. Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords. @@ -131,7 +138,7 @@ fn main() -> ExitCode { fn run() -> Result<()> { let mut args = std::env::args_os().skip(1); - let argument = args.next(); + let mut argument = args.next(); if argument .as_ref() .is_some_and(|arg| arg == "--help" || arg == "-h") @@ -139,7 +146,20 @@ fn run() -> Result<()> { print!("{HELP}"); return Ok(()); } + if std::env::var_os("PGSSLROOTCERT").is_some() { + return Err("Unset PGSSLROOTCERT and select a CA bundle per connection, or use --ca-bundle PATH on the CLI.".into()); + } + let root_cert = if argument.as_ref().is_some_and(|arg| arg == "--ca-bundle") { + let path = args.next().ok_or("--ca-bundle requires a PEM file path")?; + argument = Some(args.next().ok_or("--ca-bundle requires a PostgreSQL connection string")?); + Some(path) + } else { + None + }; let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite"); + if sqlite && root_cert.is_some() { + return Err("--ca-bundle is only supported for PostgreSQL".into()); + } let connection = if sqlite { Some( args.next() @@ -163,6 +183,7 @@ fn run() -> Result<()> { connection .to_str() .ok_or("connection string must be UTF-8")?, + root_cert.as_deref().map(Path::new), ) } })