feat: select and save CA bundles per PostgreSQL connection
This commit is contained in:
+144
-17
@@ -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));
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user