Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)
* feat: editor settings model + typed clamped defaults (Task 1) * feat: carry SSH config + ssh_password to backend DbConfig (Task 2) * feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3) * feat: csv parser + shared export util (Task 4) * feat: rustls TLS connector factory with modes + client auth (Task 5) * feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6) * feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7) * feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8) * feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9) * feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10) * feat: fetch SSH secrets into dbConnect + save on connection form (Task 11) * feat: Editor settings tab UI (Task 12) * feat: QueryEditor applies editor settings live (Task 13) * feat: ImportDialog with CSV/JSON preview + column mapping (Task 14) * feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15) * fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16) * docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17) * feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop) * fix: use theme-consistent red classes for danger menu items (text-error was undefined) * feat: changes queue as tab-bar popover + amber pending border * feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S) * refactor: drop per-card status label from changes popover cards * feat: green completion indicator on committed cards + auto-close tabs of dropped tables * docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
+318
-74
@@ -1,110 +1,112 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::db::tls::TlsDecision;
|
||||
use crate::models::SshConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate
|
||||
/// verification is meaningless: `verify-ca`/`verify-full` degrade to
|
||||
/// encrypt-only `require`. A direct (non-tunneled) connection honors the
|
||||
/// user's mode unchanged.
|
||||
pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision {
|
||||
if via_tunnel && matches!(d, TlsDecision::Verify) {
|
||||
TlsDecision::Require
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
auth_method: String,
|
||||
) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
/// A live tunnel handle. `closer` drops the listener + ssh session when called.
|
||||
pub struct Tunnel {
|
||||
pub local_port: u16,
|
||||
closer: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Create a tunnel handle with no resources to clean up (test backend).
|
||||
pub fn fake(port: u16) -> Self {
|
||||
Tunnel {
|
||||
local_port: port,
|
||||
closer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an active SSH tunnel connection.
|
||||
#[derive(Debug)]
|
||||
struct SshTunnel {
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
/// Backend that actually establishes SSH tunnels.
|
||||
///
|
||||
/// The manager only does bookkeeping; opening/closing the OS-level tunnel is
|
||||
/// delegated here so it can be faked in tests.
|
||||
pub trait TunnelBackend: Send + Sync {
|
||||
/// Open a tunnel to `remote_host:remote_port` via `cfg` and return a
|
||||
/// handle exposing the bound local port.
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String>;
|
||||
}
|
||||
|
||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||
///
|
||||
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
|
||||
/// or `async-ssh2`) will be added in a later task. Currently the manager
|
||||
/// stores mock entries when validation passes.
|
||||
#[derive(Debug)]
|
||||
/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks.
|
||||
/// The actual SSH connectivity is delegated to a `TunnelBackend` so the
|
||||
/// manager's behavior is unit-testable with a fake backend.
|
||||
pub struct SshTunnelManager {
|
||||
tunnels: HashMap<String, SshTunnel>,
|
||||
tunnels: HashMap<String, Tunnel>,
|
||||
backend: Arc<dyn TunnelBackend>,
|
||||
}
|
||||
|
||||
impl SshTunnelManager {
|
||||
/// Create a new empty tunnel manager.
|
||||
pub fn new() -> Self {
|
||||
/// Create a new tunnel manager backed by `backend`.
|
||||
pub fn new(backend: Arc<dyn TunnelBackend>) -> Self {
|
||||
SshTunnelManager {
|
||||
tunnels: HashMap::new(),
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel for the given config.
|
||||
///
|
||||
/// Returns the local port on success.
|
||||
///
|
||||
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
|
||||
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
|
||||
/// when `config.is_valid()` passes.
|
||||
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
|
||||
if !config.is_valid() {
|
||||
/// Returns the local port on success. Replaces any existing tunnel for
|
||||
/// the same key (closing the old one).
|
||||
pub fn open_tunnel(
|
||||
&mut self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<u16, String> {
|
||||
if !cfg.is_valid() {
|
||||
return Err("invalid SSH configuration".to_string());
|
||||
}
|
||||
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
|
||||
// For now, store a mock entry with local_port = 15432.
|
||||
self.tunnels.insert(
|
||||
key.to_string(),
|
||||
SshTunnel {
|
||||
local_port: 15432,
|
||||
remote_host: config.host.clone(),
|
||||
remote_port: config.port,
|
||||
},
|
||||
);
|
||||
Ok(15432)
|
||||
let tunnel = self
|
||||
.backend
|
||||
.open(key, cfg, remote_host, remote_port, password, passphrase)?;
|
||||
let port = tunnel.local_port;
|
||||
if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Close and remove the SSH tunnel for the given key.
|
||||
///
|
||||
/// TODO: When real SSH is implemented, this should disconnect the
|
||||
/// session and free the local port.
|
||||
pub fn close_tunnel(&mut self, key: &str) {
|
||||
self.tunnels.remove(key);
|
||||
if let Some(t) = self.tunnels.remove(key) {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all active SSH tunnels.
|
||||
pub fn close_all(&mut self) {
|
||||
self.tunnels.clear();
|
||||
let tunnels = std::mem::take(&mut self.tunnels);
|
||||
for (_, t) in tunnels {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the local port for an active tunnel, if any.
|
||||
@@ -112,12 +114,122 @@ impl SshTunnelManager {
|
||||
self.tunnels.get(key).map(|t| t.local_port)
|
||||
}
|
||||
|
||||
/// Clone of the active backend, for handing into `spawn_blocking` so the
|
||||
/// blocking ssh2 work never blocks an async runtime thread.
|
||||
pub fn backend_clone(&self) -> Arc<dyn TunnelBackend> {
|
||||
self.backend.clone()
|
||||
}
|
||||
|
||||
/// Insert an already-opened tunnel under `key`, closing any previous one.
|
||||
pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) {
|
||||
if let Some(old) = self.tunnels.insert(key, tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of active tunnels.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.tunnels.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH
|
||||
/// host over a blocking socket, and pumps data between the local client and
|
||||
/// the remote DB over an SSH direct-tcpip channel.
|
||||
///
|
||||
/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call
|
||||
/// sites because `ssh2::Session` is purely blocking.
|
||||
pub struct Ssh2Backend;
|
||||
|
||||
impl TunnelBackend for Ssh2Backend {
|
||||
fn open(
|
||||
&self,
|
||||
_key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
use ssh2::Session;
|
||||
|
||||
// Loopback-only listener with an ephemeral port.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("bind local tunnel port: {e}"))?;
|
||||
let local_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("local tunnel address: {e}"))?
|
||||
.port();
|
||||
|
||||
let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.map_err(|e| format!("connect ssh host: {e}"))?;
|
||||
let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().map_err(|e| format!("ssh handshake: {e}"))?;
|
||||
|
||||
match cfg.auth_method.as_str() {
|
||||
"key" => {
|
||||
let path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||
"private_key_path required for key auth".to_string()
|
||||
})?;
|
||||
session
|
||||
.userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase)
|
||||
.map_err(|e| format!("ssh key auth: {e}"))?;
|
||||
}
|
||||
_ => session
|
||||
.userauth_password(&cfg.user, password.unwrap_or(""))
|
||||
.map_err(|e| format!("ssh password auth: {e}"))?,
|
||||
}
|
||||
if !session.authenticated() {
|
||||
return Err("SSH authentication failed".into());
|
||||
}
|
||||
|
||||
let remote_host = remote_host.to_string();
|
||||
let session = Arc::new(std::sync::Mutex::new(session));
|
||||
let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut local, _)) = listener.accept() {
|
||||
// Open the direct-tcpip channel to the remote DB. `Channel` is
|
||||
// cloneable (Arc-shared inner), so one clone per direction
|
||||
// lets two pump threads copy data in parallel.
|
||||
let mut channel = match session.lock().unwrap().channel_direct_tcpip(
|
||||
&remote_host,
|
||||
remote_port as u16,
|
||||
None,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
// The accepted socket stays owned by this thread; when the
|
||||
// tunnel is closed the closer wakes us, we drop `local` and
|
||||
// the pumps end on EOF/broken pipe.
|
||||
let mut upstream = channel.clone();
|
||||
let down = local.try_clone();
|
||||
let pump = match down {
|
||||
Ok(down) => Some(std::thread::spawn(move || {
|
||||
let mut down = down;
|
||||
// client -> remote DB
|
||||
let _ = std::io::copy(&mut down, &mut upstream);
|
||||
})),
|
||||
Err(_) => None,
|
||||
};
|
||||
// remote DB -> client (this thread)
|
||||
let _ = std::io::copy(&mut channel, &mut local);
|
||||
if let Some(p) = pump {
|
||||
let _ = p.join();
|
||||
}
|
||||
}
|
||||
let _ = closer_rx.recv();
|
||||
});
|
||||
Ok(Tunnel {
|
||||
local_port,
|
||||
closer: Some(Box::new(move || {
|
||||
let _ = closer_tx.send(());
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -176,4 +288,136 @@ mod tests {
|
||||
);
|
||||
assert!(config.is_valid(), "port 1 should be valid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tunnel manager tests (fake backend)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeBackend {
|
||||
opens: std::sync::Mutex<Vec<String>>,
|
||||
next_port: u16,
|
||||
}
|
||||
|
||||
impl Clone for FakeBackend {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()),
|
||||
next_port: self.next_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelBackend for FakeBackend {
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
_cfg: &crate::models::SshConfig,
|
||||
_remote_host: &str,
|
||||
_remote_port: u16,
|
||||
_pw: Option<&str>,
|
||||
_pp: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
self.opens.lock().unwrap().push(key.to_string());
|
||||
let p = self.next_port;
|
||||
Ok(Tunnel::fake(p))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_open_and_get_port() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 22222,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend.clone());
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let port = mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(port, 22222);
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(22222));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_invalid_config_errors() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into());
|
||||
assert!(mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_removes_tunnel() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
mgr.close_tunnel("c1");
|
||||
assert_eq!(mgr.get_local_port("c1"), None);
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunneled_tls_is_downgraded_to_require() {
|
||||
// verify-full through a tunnel degrades to encrypt-only `require`
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true),
|
||||
crate::db::tls::TlsDecision::Require
|
||||
);
|
||||
// direct (non-tunneled) connection keeps the user's mode
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false),
|
||||
crate::db::tls::TlsDecision::Verify
|
||||
);
|
||||
// disable stays disabled regardless of tunneling
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true),
|
||||
crate::db::tls::TlsDecision::Disable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_backend_clone_returns_backend() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 7,
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = SshTunnelManager::new(backend.clone());
|
||||
// The cloned Arc points at the same fake backend.
|
||||
let cloned = mgr.backend_clone();
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let tunnel = cloned
|
||||
.open("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(tunnel.local_port, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_insert_tunnel_replaces_and_closes_old() {
|
||||
let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default()));
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(1111));
|
||||
// Re-inserting under the same key replaces the old tunnel.
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(2222));
|
||||
assert_eq!(mgr.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_all() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.close_all();
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user