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

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