feat(remote): persist SPKI pins and machine configs to SQLite

- remote_machines table in sessions.db (id, label, url, token, auto_connect,
  spki_pins as JSON array, created_at, updated_at)
- session/machines.rs: save/load/delete/update_pins CRUD operations
- RemoteManager: set_session_db() + load_from_db() for startup restoration
- All mutations persist: add_machine, remove_machine, add_spki_pin,
  remove_spki_pin, TOFU auto-store — pins survive restart
- 197 cargo tests passing, 0 warnings
This commit is contained in:
Hibryda 2026-03-18 02:18:17 +01:00
parent d1463d4d1e
commit 538a31f85c
4 changed files with 170 additions and 2 deletions

View file

@ -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,

View file

@ -68,12 +68,75 @@ struct RemoteMachine {
pub struct RemoteManager {
machines: Arc<Mutex<HashMap<String, RemoteMachine>>>,
session_db: Option<Arc<crate::session::SessionDb>>,
}
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<crate::session::SessionDb>) {
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,

View file

@ -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<String>,
}
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<Vec<RemoteMachineRecord>, 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<String> = 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(())
}
}

View file

@ -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(())