feat: select and save CA bundles per PostgreSQL connection

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:09:50 +02:00
parent 128c6ee829
commit 08afa1e495
3 changed files with 188 additions and 46 deletions
+144 -17
View File
@@ -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\ Esc Leave Insert first, then return to explorer\n\n\
Connection / password windows\n\ Connection / password windows\n\
Tab / Shift-Tab Change fields\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\ Enter Edit provider / save settings / connect\n\
Ctrl-U / paste Clear / enter text in a focused field\n\ Ctrl-U / paste Clear / enter text in a focused field\n\
Esc Cancel and discard edits\n\ Esc Cancel and discard edits\n\
Optional Name sets the sidebar label; blank uses the default.\n\ Optional Name sets the sidebar label; blank uses the default.\n\
Connect saves settings only on success; e edits save without connecting.\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.", Passwords are never saved.",
) )
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
@@ -627,7 +629,13 @@ impl Profiles {
"ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''", "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| { .query_map([], |row| {
let mut form = Form { let mut form = Form {
id: Some(row.get(7)?), id: Some(row.get(7)?),
@@ -635,6 +643,7 @@ impl Profiles {
provider: usize::from(row.get::<_, u8>(0)?), provider: usize::from(row.get::<_, u8>(0)?),
tls: usize::from(row.get::<_, u8>(5)?), tls: usize::from(row.get::<_, u8>(5)?),
path: Input::from(row.get::<_, String>(6)?.as_str()), path: Input::from(row.get::<_, String>(6)?.as_str()),
ca_bundle: row.get::<_, Option<String>>(9)?.map(PathBuf::from),
..Form::default() ..Form::default()
}; };
for index in 0..4 { for index in 0..4 {
@@ -660,6 +669,7 @@ impl Profiles {
} }
}), }),
tls: form.tls, tls: form.tls,
ca_bundle: (form.provider == 0).then(|| form.ca_bundle.clone()).flatten(),
path: form.path.clone(), path: form.path.clone(),
error: None, error: None,
reconnect: false, reconnect: false,
@@ -678,12 +688,12 @@ impl Profiles {
saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?);
} }
self.storage.execute( self.storage.execute(
"INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name) "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) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
provider = excluded.provider, host = excluded.host, port = excluded.port, provider = excluded.provider, host = excluded.host, port = excluded.port,
database_name = excluded.database_name, username = excluded.username, 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![ rusqlite::params![
saved.provider as u8, saved.provider as u8,
saved.postgres[0].value, saved.postgres[0].value,
@@ -693,7 +703,8 @@ impl Profiles {
saved.tls as u8, saved.tls as u8,
saved.path.value, saved.path.value,
saved.id, 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()); saved.id.get_or_insert(self.storage.last_insert_rowid());
@@ -723,6 +734,7 @@ struct Form {
focus: usize, focus: usize,
postgres: [Input; 5], postgres: [Input; 5],
tls: usize, tls: usize,
ca_bundle: Option<PathBuf>,
path: Input, path: Input,
error: Option<String>, error: Option<String>,
reconnect: bool, reconnect: bool,
@@ -736,6 +748,7 @@ impl Default for Form {
provider: 0, provider: 0,
focus: 0, focus: 0,
tls: 0, tls: 0,
ca_bundle: None,
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from), postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
path: Input::default(), path: Input::default(),
error: None, error: None,
@@ -749,10 +762,41 @@ impl Form {
if self.provider == 1 { if self.provider == 1 {
2 2
} else { } 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<Database> { fn connect(&self) -> Result<Database> {
if self.provider == 1 { if self.provider == 1 {
if self.path.value.is_empty() { if self.path.value.is_empty() {
@@ -760,10 +804,13 @@ impl Form {
} }
return Database::open_sqlite(Path::new(&self.path.value)); 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<Config> { fn postgres_config(&self) -> Result<Config> {
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; let [host, port, database, user, password] = &self.postgres;
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
return Err("PostgreSQL requires a host, database, and user.".into()); return Err("PostgreSQL requires a host, database, and user.".into());
@@ -824,6 +871,7 @@ impl Form {
} }
fn edit(&mut self, key: KeyEvent) { fn edit(&mut self, key: KeyEvent) {
self.error = None;
let count = self.name_focus() + 1; let count = self.name_focus() + 1;
match key.code { match key.code {
KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::Tab => self.focus = (self.focus + 1) % count,
@@ -834,7 +882,19 @@ impl Form {
KeyCode::Left | KeyCode::Right KeyCode::Left | KeyCode::Right
if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) => 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() { if let Some(input) = self.input_mut() {
@@ -842,7 +902,6 @@ impl Form {
} }
} }
} }
self.error = None;
} }
fn label(&self) -> String { fn label(&self) -> String {
@@ -878,12 +937,13 @@ impl Form {
) )
} else { } else {
format!( 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[0].value),
crate::db::display_text(&self.postgres[1].value), crate::db::display_text(&self.postgres[1].value),
crate::db::display_text(&self.postgres[2].value), crate::db::display_text(&self.postgres[2].value),
crate::db::display_text(&self.postgres[3].value), crate::db::display_text(&self.postgres[3].value),
["Require", "Prefer", "Disable"][self.tls], ["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) { 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 { if screen.width < 74 || screen.height < minimum_height {
frame.render_widget(Clear, screen); frame.render_widget(Clear, screen);
frame.render_widget( frame.render_widget(
@@ -1009,6 +1069,7 @@ impl Form {
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0), Constraint::Min(0),
]) ])
.split(details); .split(details);
@@ -1050,6 +1111,22 @@ impl Form {
), ),
pairs[2][usize::from(self.reconnect)], 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 { let tls_notice = match self.tls {
0 => "TLS required; certificates and hostnames are verified.", 0 => "TLS required; certificates and hostnames are verified.",
1 => "TLS preferred; the server may fall back to unencrypted traffic.", 1 => "TLS preferred; the server may fall back to unencrypted traffic.",
@@ -1058,10 +1135,10 @@ impl Form {
frame.render_widget( frame.render_widget(
Paragraph::new(vec![ Paragraph::new(vec![
Line::raw(tls_notice), 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 }), .wrap(Wrap { trim: false }),
rows[3], rows[4],
); );
} }
let message = self.error.as_deref().unwrap_or(if self.reconnect { let message = self.error.as_deref().unwrap_or(if self.reconnect {
@@ -1080,9 +1157,9 @@ impl Form {
status, status,
); );
frame.render_widget(Paragraph::new(if self.reconnect { 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 { } 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); }).style(Style::default().fg(Color::DarkGray)), help);
} }
} }
@@ -1375,6 +1452,55 @@ impl Input {
mod tests { mod tests {
use super::*; 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] #[test]
fn clearing_passwords_wipes_the_live_buffer_and_resets_unicode_cursor() { fn clearing_passwords_wipes_the_live_buffer_and_resets_unicode_cursor() {
for cancel in [true, false] { for cancel in [true, false] {
@@ -1706,7 +1832,7 @@ mod tests {
..Form::default() ..Form::default()
}; };
form.postgres[4] = Input::from("test-secret-é"); 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| { terminal.draw(|frame| {
let area = frame.area(); let area = frame.area();
form.draw(frame, area); form.draw(frame, area);
@@ -1791,6 +1917,7 @@ mod tests {
assert!(form.input().is_none()); assert!(form.input().is_none());
form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::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.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
form.input_mut().unwrap().paste("Warehouse"); form.input_mut().unwrap().paste("Warehouse");
for value in ["db.example", "5440", "warehouse", "inspector"] { for value in ["db.example", "5440", "warehouse", "inspector"] {
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
+11 -17
View File
@@ -53,19 +53,15 @@ pub struct QueryResult {
} }
impl Database { impl Database {
pub fn connect(connection: &str) -> Result<Self> { pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result<Self> {
Self::connect_postgres(connection.parse()?) Self::connect_postgres(connection.parse()?, root_cert)
} }
pub fn connect_postgres(mut config: Config) -> Result<Self> { pub fn connect_postgres(mut config: Config, root_cert: Option<&Path>) -> Result<Self> {
if config.get_connect_timeout().is_none() { if config.get_connect_timeout().is_none() {
config.connect_timeout(Duration::from_secs(5)); 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)?);
let tls = MakeTlsConnector::new(postgres_tls(
config.get_ssl_mode(),
root_cert.as_deref().map(Path::new),
)?);
let mut client = config.connect(tls)?; let mut client = config.connect(tls)?;
// Views can invoke functions: enforce read-only on the server, not just in the UI. // Views can invoke functions: enforce read-only on the server, not just in the UI.
client.batch_execute( client.batch_execute(
@@ -284,14 +280,14 @@ fn postgres_tls(mode: SslMode, root_cert: Option<&Path>) -> Result<TlsConnector>
let mut tls = TlsConnector::builder(); let mut tls = TlsConnector::builder();
if let Some(path) = root_cert { if let Some(path) = root_cert {
if mode != SslMode::Require { 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) 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) 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() { 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. // An explicit bundle replaces system roots; certificate and hostname checks stay enabled.
tls.disable_built_in_roots(true); 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<()> { fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> {
let bundle = tempfile::NamedTempFile::new()?; let bundle = tempfile::NamedTempFile::new()?;
for mode in [SslMode::Prefer, SslMode::Disable] { for mode in [SslMode::Prefer, SslMode::Disable] {
let error = postgres_tls(mode, Some(bundle.path())).unwrap_err(); assert!(postgres_tls(mode, Some(bundle.path())).is_err());
assert!(error.to_string().contains("sslmode=require"));
} }
for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] { for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] {
std::fs::write(bundle.path(), contents)?; std::fs::write(bundle.path(), contents)?;
assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err()); assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err());
} }
let missing = bundle.path().with_extension("missing"); let missing = bundle.path().with_extension("missing");
let error = postgres_tls(SslMode::Require, Some(&missing)).unwrap_err(); assert!(postgres_tls(SslMode::Require, Some(&missing)).is_err());
assert!(error.to_string().contains("Cannot read PGSSLROOTCERT"));
Ok(()) Ok(())
} }
@@ -396,7 +390,7 @@ mod tests {
CREATE VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\"; CREATE VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\";
CREATE TABLE {schema}.empty_table (id integer);" 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 relations = db.relations()?;
let table = relations let table = relations
.iter() .iter()
+33 -12
View File
@@ -16,6 +16,7 @@ const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection
Usage: Usage:
dbdash [POSTGRES_CONNECTION_STRING] dbdash [POSTGRES_CONNECTION_STRING]
dbdash --ca-bundle PATH POSTGRES_CONNECTION_STRING
dbdash --sqlite PATH dbdash --sqlite PATH
dbdash --help 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=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections. Use sslmode=disable only for trusted local connections.
For private CAs (including Amazon RDS), set PGSSLROOTCERT to a PEM CA bundle: For private CAs (including Amazon RDS), put PEM bundles (.pem or .crt) in
PGSSLROOTCERT="$HOME/.config/dbdash/rds-us-east-1.pem" cargo run $XDG_CONFIG_HOME/dbdash (default: ~/.config/dbdash), then launch normally:
This applies to saved and CLI PostgreSQL connections. Choose Require TLS in the cargo run
connection window, or sslmode=require on the CLI; Prefer/Disable are rejected. In the connection window, Tab to CA bundle and use Left/Right to choose a file
Only the supplied bundle is trusted while set; macOS/system trust is unchanged. or System trust. The selection is saved per connection and shown in database info.
Certificates and hostnames are verified before authentication; no plaintext fallback. Selecting a bundle forces Require TLS; choose System trust before changing TLS mode.
Missing, empty, or invalid CA files block the connection. Use a bundle obtained The selector rescans on each change; missing saved files are not silently replaced.
from the trusted issuer, not the certificate presented by an unverified server. 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: 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 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. 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: Navigation:
? Show keybindings in Normal mode (Esc/Enter/? closes) ? 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. 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) 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. 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. Enter saves without connecting, including when the database is offline.
Optional Name controls the sidebar label; leave it blank for the default label. 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. 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. 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 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; 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. only on success; failures keep the previous settings and connection. Esc discards edits.
Passwords are masked, never saved, and clear on success or cancellation. 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. Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords.
@@ -131,7 +138,7 @@ fn main() -> ExitCode {
fn run() -> Result<()> { fn run() -> Result<()> {
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let argument = args.next(); let mut argument = args.next();
if argument if argument
.as_ref() .as_ref()
.is_some_and(|arg| arg == "--help" || arg == "-h") .is_some_and(|arg| arg == "--help" || arg == "-h")
@@ -139,7 +146,20 @@ fn run() -> Result<()> {
print!("{HELP}"); print!("{HELP}");
return Ok(()); 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"); 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 { let connection = if sqlite {
Some( Some(
args.next() args.next()
@@ -163,6 +183,7 @@ fn run() -> Result<()> {
connection connection
.to_str() .to_str()
.ok_or("connection string must be UTF-8")?, .ok_or("connection string must be UTF-8")?,
root_cert.as_deref().map(Path::new),
) )
} }
}) })