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:
2026-08-02 20:38:23 +08:00
committed by GitHub
parent e32fe7967c
commit e0c0db8352
68 changed files with 3885 additions and 553 deletions
+318 -74
View File
@@ -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);
}
}