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\
|
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));
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user