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\
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<String>>(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<PathBuf>,
path: Input,
error: Option<String>,
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<Database> {
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<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;
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));
+11 -17
View File
@@ -53,19 +53,15 @@ pub struct QueryResult {
}
impl Database {
pub fn connect(connection: &str) -> Result<Self> {
Self::connect_postgres(connection.parse()?)
pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result<Self> {
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() {
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<TlsConnector>
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()
+33 -12
View File
@@ -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),
)
}
})