diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90b71ff..d23b370 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -389,7 +389,9 @@ pub fn run() { let fs_watcher = Arc::new(ProjectFsWatcher::new()); let ctx_db = Arc::new(ctx::CtxDb::new_with_path(config.ctx_db_path.clone())); let memora_db = Arc::new(memora::MemoraDb::new_with_path(config.memora_db_path.clone())); - let remote_manager = Arc::new(RemoteManager::new()); + let mut remote_mgr = RemoteManager::new(); + remote_mgr.set_session_db(session_db.clone()); + let remote_manager = Arc::new(remote_mgr); // Initialize FTS5 search database let search_db_path = config.data_dir.join("agor").join("search.db"); @@ -408,6 +410,12 @@ pub fn run() { let btmsg_db_path = config.btmsg_db_path(); spawn_wal_checkpoint_task(sessions_db_path, btmsg_db_path); + // Load saved remote machines from database before managing state + let rm_clone = remote_manager.clone(); + tauri::async_runtime::spawn(async move { + rm_clone.load_from_db().await; + }); + app.manage(AppState { pty_manager, sidecar_manager, diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 7d15f68..e9da5fc 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -68,12 +68,75 @@ struct RemoteMachine { pub struct RemoteManager { machines: Arc>>, + session_db: Option>, } impl RemoteManager { pub fn new() -> Self { Self { machines: Arc::new(Mutex::new(HashMap::new())), + session_db: None, + } + } + + /// Set the session database for persistence. Call once after SessionDb is created. + pub fn set_session_db(&mut self, db: Arc) { + self.session_db = Some(db); + } + + /// Load previously saved machines from SQLite. Call once on startup. + pub async fn load_from_db(&self) { + let db = match &self.session_db { + Some(db) => db, + None => return, + }; + match db.load_remote_machines() { + Ok(records) => { + let mut machines = self.machines.lock().await; + for rec in records { + machines.insert(rec.id.clone(), RemoteMachine { + id: rec.id, + config: RemoteMachineConfig { + label: rec.label, + url: rec.url, + token: rec.token, + auto_connect: rec.auto_connect, + spki_pins: rec.spki_pins, + }, + status: "disconnected".to_string(), + connection: None, + cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + } + log::info!("Loaded {} remote machines from database", machines.len()); + } + Err(e) => log::warn!("Failed to load remote machines: {e}"), + } + } + + /// Persist a machine config to SQLite (best-effort). + fn persist_machine(&self, machine: &RemoteMachine) { + if let Some(db) = &self.session_db { + let rec = crate::session::RemoteMachineRecord { + id: machine.id.clone(), + label: machine.config.label.clone(), + url: machine.config.url.clone(), + token: machine.config.token.clone(), + auto_connect: machine.config.auto_connect, + spki_pins: machine.config.spki_pins.clone(), + }; + if let Err(e) = db.save_remote_machine(&rec) { + log::warn!("Failed to persist remote machine {}: {e}", machine.id); + } + } + } + + /// Persist just the pins for a machine (best-effort). + fn persist_pins(&self, machine_id: &str, pins: &[String]) { + if let Some(db) = &self.session_db { + if let Err(e) = db.update_remote_machine_pins(machine_id, pins) { + log::warn!("Failed to persist SPKI pins for {machine_id}: {e}"); + } } } @@ -98,6 +161,7 @@ impl RemoteManager { connection: None, cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; + self.persist_machine(&machine); self.machines.lock().await.insert(id.clone(), machine); id } @@ -114,6 +178,9 @@ impl RemoteManager { } machines.remove(machine_id) .ok_or_else(|| format!("Machine {machine_id} not found"))?; + if let Some(db) = &self.session_db { + let _ = db.delete_remote_machine(machine_id); + } Ok(()) } @@ -125,6 +192,7 @@ impl RemoteManager { if !machine.config.spki_pins.contains(&pin) { machine.config.spki_pins.push(pin); } + self.persist_pins(machine_id, &machine.config.spki_pins); Ok(()) } @@ -134,6 +202,7 @@ impl RemoteManager { let machine = machines.get_mut(machine_id) .ok_or_else(|| format!("Machine {machine_id} not found"))?; machine.config.spki_pins.retain(|p| p != pin); + self.persist_pins(machine_id, &machine.config.spki_pins); Ok(()) } @@ -188,6 +257,7 @@ impl RemoteManager { let mut machines = self.machines.lock().await; if let Some(machine) = machines.get_mut(machine_id) { machine.config.spki_pins.push(hash.clone()); + self.persist_pins(machine_id, &machine.config.spki_pins); } let _ = app.emit("remote-spki-tofu", &serde_json::json!({ "machineId": machine_id, diff --git a/src-tauri/src/session/machines.rs b/src-tauri/src/session/machines.rs new file mode 100644 index 0000000..011a847 --- /dev/null +++ b/src-tauri/src/session/machines.rs @@ -0,0 +1,77 @@ +// Remote machine persistence — stores machine configs + SPKI pins in SQLite + +use crate::error::AppError; +use crate::session::SessionDb; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteMachineRecord { + pub id: String, + pub label: String, + pub url: String, + pub token: String, + pub auto_connect: bool, + pub spki_pins: Vec, +} + +impl SessionDb { + pub fn save_remote_machine(&self, machine: &RemoteMachineRecord) -> Result<(), AppError> { + let conn = self.conn.lock().unwrap(); + let pins_json = serde_json::to_string(&machine.spki_pins)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + conn.execute( + "INSERT OR REPLACE INTO remote_machines (id, label, url, token, auto_connect, spki_pins, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, COALESCE((SELECT created_at FROM remote_machines WHERE id = ?1), ?7), ?7)", + rusqlite::params![machine.id, machine.label, machine.url, machine.token, machine.auto_connect as i32, pins_json, now], + )?; + Ok(()) + } + + pub fn load_remote_machines(&self) -> Result, AppError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, label, url, token, auto_connect, spki_pins FROM remote_machines" + )?; + let rows = stmt.query_map([], |row| { + let pins_json: String = row.get("spki_pins")?; + let pins: Vec = serde_json::from_str(&pins_json).unwrap_or_default(); + Ok(RemoteMachineRecord { + id: row.get("id")?, + label: row.get("label")?, + url: row.get("url")?, + token: row.get("token")?, + auto_connect: row.get::<_, i32>("auto_connect")? != 0, + spki_pins: pins, + }) + })?; + let mut machines = Vec::new(); + for row in rows { + machines.push(row?); + } + Ok(machines) + } + + pub fn delete_remote_machine(&self, id: &str) -> Result<(), AppError> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM remote_machines WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn update_remote_machine_pins(&self, id: &str, pins: &[String]) -> Result<(), AppError> { + let conn = self.conn.lock().unwrap(); + let pins_json = serde_json::to_string(pins)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + conn.execute( + "UPDATE remote_machines SET spki_pins = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![pins_json, now, id], + )?; + Ok(()) + } +} diff --git a/src-tauri/src/session/mod.rs b/src-tauri/src/session/mod.rs index dd47ccf..4d5bcbd 100644 --- a/src-tauri/src/session/mod.rs +++ b/src-tauri/src/session/mod.rs @@ -8,6 +8,7 @@ mod ssh; mod agents; mod metrics; mod anchors; +mod machines; pub use sessions::Session; pub use layout::LayoutState; @@ -15,6 +16,7 @@ pub use ssh::SshSession; pub use agents::{AgentMessageRecord, ProjectAgentState}; pub use metrics::SessionMetric; pub use anchors::SessionAnchorRecord; +pub use machines::RemoteMachineRecord; use rusqlite::Connection; use std::path::PathBuf; @@ -166,7 +168,18 @@ impl SessionDb { created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_session_anchors_project - ON session_anchors(project_id);" + ON session_anchors(project_id); + + CREATE TABLE IF NOT EXISTS remote_machines ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + url TEXT NOT NULL, + token TEXT NOT NULL, + auto_connect INTEGER NOT NULL DEFAULT 0, + spki_pins TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );" ).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?; Ok(())