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:
parent
d1463d4d1e
commit
538a31f85c
4 changed files with 170 additions and 2 deletions
|
|
@ -389,7 +389,9 @@ pub fn run() {
|
||||||
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
||||||
let ctx_db = Arc::new(ctx::CtxDb::new_with_path(config.ctx_db_path.clone()));
|
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 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
|
// Initialize FTS5 search database
|
||||||
let search_db_path = config.data_dir.join("agor").join("search.db");
|
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();
|
let btmsg_db_path = config.btmsg_db_path();
|
||||||
spawn_wal_checkpoint_task(sessions_db_path, 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 {
|
app.manage(AppState {
|
||||||
pty_manager,
|
pty_manager,
|
||||||
sidecar_manager,
|
sidecar_manager,
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,75 @@ struct RemoteMachine {
|
||||||
|
|
||||||
pub struct RemoteManager {
|
pub struct RemoteManager {
|
||||||
machines: Arc<Mutex<HashMap<String, RemoteMachine>>>,
|
machines: Arc<Mutex<HashMap<String, RemoteMachine>>>,
|
||||||
|
session_db: Option<Arc<crate::session::SessionDb>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteManager {
|
impl RemoteManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
machines: Arc::new(Mutex::new(HashMap::new())),
|
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,
|
connection: None,
|
||||||
cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
|
self.persist_machine(&machine);
|
||||||
self.machines.lock().await.insert(id.clone(), machine);
|
self.machines.lock().await.insert(id.clone(), machine);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +178,9 @@ impl RemoteManager {
|
||||||
}
|
}
|
||||||
machines.remove(machine_id)
|
machines.remove(machine_id)
|
||||||
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
||||||
|
if let Some(db) = &self.session_db {
|
||||||
|
let _ = db.delete_remote_machine(machine_id);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +192,7 @@ impl RemoteManager {
|
||||||
if !machine.config.spki_pins.contains(&pin) {
|
if !machine.config.spki_pins.contains(&pin) {
|
||||||
machine.config.spki_pins.push(pin);
|
machine.config.spki_pins.push(pin);
|
||||||
}
|
}
|
||||||
|
self.persist_pins(machine_id, &machine.config.spki_pins);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +202,7 @@ impl RemoteManager {
|
||||||
let machine = machines.get_mut(machine_id)
|
let machine = machines.get_mut(machine_id)
|
||||||
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
||||||
machine.config.spki_pins.retain(|p| p != pin);
|
machine.config.spki_pins.retain(|p| p != pin);
|
||||||
|
self.persist_pins(machine_id, &machine.config.spki_pins);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +257,7 @@ impl RemoteManager {
|
||||||
let mut machines = self.machines.lock().await;
|
let mut machines = self.machines.lock().await;
|
||||||
if let Some(machine) = machines.get_mut(machine_id) {
|
if let Some(machine) = machines.get_mut(machine_id) {
|
||||||
machine.config.spki_pins.push(hash.clone());
|
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!({
|
let _ = app.emit("remote-spki-tofu", &serde_json::json!({
|
||||||
"machineId": machine_id,
|
"machineId": machine_id,
|
||||||
|
|
|
||||||
77
src-tauri/src/session/machines.rs
Normal file
77
src-tauri/src/session/machines.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ mod ssh;
|
||||||
mod agents;
|
mod agents;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod anchors;
|
mod anchors;
|
||||||
|
mod machines;
|
||||||
|
|
||||||
pub use sessions::Session;
|
pub use sessions::Session;
|
||||||
pub use layout::LayoutState;
|
pub use layout::LayoutState;
|
||||||
|
|
@ -15,6 +16,7 @@ pub use ssh::SshSession;
|
||||||
pub use agents::{AgentMessageRecord, ProjectAgentState};
|
pub use agents::{AgentMessageRecord, ProjectAgentState};
|
||||||
pub use metrics::SessionMetric;
|
pub use metrics::SessionMetric;
|
||||||
pub use anchors::SessionAnchorRecord;
|
pub use anchors::SessionAnchorRecord;
|
||||||
|
pub use machines::RemoteMachineRecord;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -166,7 +168,18 @@ impl SessionDb {
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_anchors_project
|
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}"))?;
|
).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue