`status` asked a running agent and failed without one; `doctor` checked
the host and ignored the agent. Between them they answered one question
in two halves. They are now one command: device, agent, networks, host
capability, local addresses, all graded and aligned the same way.
`id` was worse than either. It spawned a whole agent to print three
facts, which took the directory lock and so failed with "owned by
another running agent instance" exactly when the answer was most wanted.
The lock exists to keep one writer over the mandatory state; reading who
this device is needs no such thing.
So both commands now prefer the running agent, which is live and
authoritative, and fall back to the state store, which takes no lock.
StateStore gains device_identity(), which reads and never writes:
load_or_create had the side effect of deciding an identity as a
consequence of asking about one.
Presentation moved out of the library. StatusReport::render is gone and
the CLI renders the structured data, so there is one renderer rather than
two that would drift. Health gains an Info level for rows that are facts
rather than checks — an endpoint id is neither good nor bad, and a column
of green next to plain data teaches the eye to ignore the column. A
report with no checks in it now ends without a summary instead of
claiming that everything checked out.
PathAddr and TransportKind grew Display impls; both were reaching the
user through {:?}, which is how "Direct via Ip(88.198.17.44:49792)" got
printed. The transport grading compares without regard to case, because
the agent answering can be a different build from the client asking and
this field's spelling has now changed once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
513 lines
19 KiB
Rust
513 lines
19 KiB
Rust
//! The mandatory state store, `state.sqlite`.
|
|
//!
|
|
//! Holds the persistent device identity, configured networks (including their
|
|
//! shared secrets, which are needed to re-derive keys after a restart), the
|
|
//! stored hostname and auto-start flags.
|
|
//!
|
|
//! Corruption is reported, never silently repaired: a damaged state store must
|
|
//! not quietly turn into a brand new identity.
|
|
//!
|
|
//! Future work will add per-author record versions, accepted signed states and
|
|
//! revocations here. When that lands, writing an event and bumping the author's
|
|
//! own counter must happen in one SQLite transaction *before* the change is
|
|
//! published to the network.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
|
use crate::state::SignedRecord;
|
|
|
|
/// Schema version written by this build.
|
|
pub const SCHEMA_VERSION: i64 = 2;
|
|
|
|
/// Key of the stored hostname setting.
|
|
const SETTING_HOSTNAME: &str = "hostname";
|
|
|
|
/// A network as persisted in the state store.
|
|
///
|
|
/// The secret is held in a [`NetworkSecret`], which redacts itself from `Debug`
|
|
/// and zeroizes on drop.
|
|
#[derive(Debug, Clone)]
|
|
pub struct StoredNetwork {
|
|
/// Derived public network identifier.
|
|
pub network_id: NetworkId,
|
|
/// Network name.
|
|
pub name: NetworkName,
|
|
/// Shared secret, needed to re-derive keys after restart.
|
|
pub secret: NetworkSecret,
|
|
/// Whether the network is activated automatically at agent startup.
|
|
pub auto_start: bool,
|
|
}
|
|
|
|
/// The mandatory state store.
|
|
#[derive(Debug)]
|
|
pub struct StateStore {
|
|
conn: Connection,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl StateStore {
|
|
/// Opens (creating if absent) the state store at `path`.
|
|
///
|
|
/// Returns [`Error::StateCorrupted`] if the file exists but is not a usable
|
|
/// database. The file is never deleted or recreated by this function.
|
|
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
|
let path = path.as_ref().to_path_buf();
|
|
let existed = path.exists();
|
|
let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted {
|
|
path: path.clone(),
|
|
reason: format!("cannot open database: {err}"),
|
|
})?;
|
|
|
|
super::restrict_path_permissions(&path)?;
|
|
super::apply_common_pragmas(&conn).map_err(|err| Error::StateCorrupted {
|
|
path: path.clone(),
|
|
reason: format!("cannot configure database: {err}"),
|
|
})?;
|
|
|
|
if existed {
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
|
.map_err(|err| Error::StateCorrupted {
|
|
path: path.clone(),
|
|
reason: format!("integrity check failed: {err}"),
|
|
})?;
|
|
if integrity != "ok" {
|
|
return Err(Error::StateCorrupted {
|
|
path,
|
|
reason: format!("integrity check reported: {integrity}"),
|
|
});
|
|
}
|
|
}
|
|
|
|
let store = Self { conn, path };
|
|
store.migrate()?;
|
|
Ok(store)
|
|
}
|
|
|
|
/// Path of the underlying file.
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
fn corrupt(&self, reason: impl std::fmt::Display) -> Error {
|
|
Error::StateCorrupted {
|
|
path: self.path.clone(),
|
|
reason: reason.to_string(),
|
|
}
|
|
}
|
|
|
|
fn migrate(&self) -> Result<()> {
|
|
let found: i64 = self
|
|
.conn
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.map_err(|err| self.corrupt(format!("cannot read schema version: {err}")))?;
|
|
|
|
if found > SCHEMA_VERSION {
|
|
return Err(Error::UnsupportedSchema {
|
|
found,
|
|
supported: SCHEMA_VERSION,
|
|
});
|
|
}
|
|
if found == SCHEMA_VERSION {
|
|
return self.verify_shape();
|
|
}
|
|
|
|
// Migration 1 -> 2: signed records that outlive a session.
|
|
if (1..2).contains(&found) {
|
|
self.conn
|
|
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 2: {err}")))?;
|
|
}
|
|
|
|
// Migration 0 -> 1: initial schema.
|
|
if found < 1 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
CREATE TABLE device_identity (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
secret_key BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE networks (
|
|
network_id BLOB PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
secret BLOB NOT NULL,
|
|
auto_start INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
PRAGMA user_version = 1;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
|
self.conn
|
|
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
|
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Confirms the expected tables exist, so that a truncated or foreign
|
|
/// database is reported rather than used.
|
|
fn verify_shape(&self) -> Result<()> {
|
|
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
|
let present: Option<String> = self
|
|
.conn
|
|
.query_row(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
|
params![table],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| self.corrupt(format!("cannot inspect schema: {err}")))?;
|
|
if present.is_none() {
|
|
return Err(self.corrupt(format!("table `{table}` is missing")));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Loads the stored device identity, if there is one.
|
|
///
|
|
/// Reads and never writes, so asking who this device is does not decide
|
|
/// it. A store that has never run an agent has no identity yet, which is
|
|
/// `None` rather than an error.
|
|
///
|
|
/// A stored key of the wrong length is a corruption error, never a reason
|
|
/// to silently mint a new identity.
|
|
pub fn device_identity(&self) -> Result<Option<DeviceIdentity>> {
|
|
let stored: Option<Vec<u8>> = self
|
|
.conn
|
|
.query_row(
|
|
"SELECT secret_key FROM device_identity WHERE id = 1",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?;
|
|
|
|
let Some(bytes) = stored else {
|
|
return Ok(None);
|
|
};
|
|
let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
|
|
self.corrupt(format!(
|
|
"stored device key has {} bytes, expected 32; refusing to replace it",
|
|
bytes.len()
|
|
))
|
|
})?;
|
|
Ok(Some(DeviceIdentity::from_secret_bytes(&bytes)))
|
|
}
|
|
|
|
/// Loads the stored device identity, creating one on first use.
|
|
pub fn load_or_create_device_identity(&self) -> Result<DeviceIdentity> {
|
|
if let Some(identity) = self.device_identity()? {
|
|
return Ok(identity);
|
|
}
|
|
|
|
let identity = DeviceIdentity::generate();
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)",
|
|
params![identity.secret_bytes().as_slice(), now_unix()],
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot store device identity: {err}")))?;
|
|
Ok(identity)
|
|
}
|
|
|
|
/// Inserts or updates a network configuration.
|
|
pub fn upsert_network(
|
|
&self,
|
|
network_id: NetworkId,
|
|
name: &NetworkName,
|
|
secret: &NetworkSecret,
|
|
auto_start: bool,
|
|
) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO networks (network_id, name, secret, auto_start, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(network_id) DO UPDATE SET
|
|
name = excluded.name,
|
|
secret = excluded.secret,
|
|
auto_start = excluded.auto_start",
|
|
params![
|
|
network_id.as_bytes().as_slice(),
|
|
name.as_str(),
|
|
secret.expose(),
|
|
auto_start as i64,
|
|
now_unix()
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store network: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Sets the auto-start flag of a configured network.
|
|
pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"UPDATE networks SET auto_start = ?2 WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice(), auto_start as i64],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update network: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes a network configuration entirely.
|
|
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"DELETE FROM networks WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot remove network: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Lists every configured network.
|
|
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
|
let mut stmt = self
|
|
.conn
|
|
.prepare("SELECT network_id, name, secret, auto_start FROM networks ORDER BY name")
|
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
let id: Vec<u8> = row.get(0)?;
|
|
let name: String = row.get(1)?;
|
|
let secret: Vec<u8> = row.get(2)?;
|
|
let auto_start: i64 = row.get(3)?;
|
|
Ok((id, name, secret, auto_start != 0))
|
|
})
|
|
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
|
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (id, name, secret, auto_start) =
|
|
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
|
let id: [u8; 32] = id
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| self.corrupt("stored network id is not 32 bytes"))?;
|
|
out.push(StoredNetwork {
|
|
network_id: NetworkId::from_bytes(id),
|
|
name: NetworkName::new(name)?,
|
|
secret: NetworkSecret::from_bytes(secret)?,
|
|
auto_start,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Reads the stored hostname, if any.
|
|
pub fn hostname(&self) -> Result<Option<String>> {
|
|
self.get_setting(SETTING_HOSTNAME)
|
|
}
|
|
|
|
/// Stores the hostname.
|
|
///
|
|
/// Today this is a local setting. In the future a rename must be a signed
|
|
/// record that revokes the specific old binding and announces the new one,
|
|
/// ideally atomically in one record.
|
|
pub fn set_hostname(&self, hostname: &str) -> Result<()> {
|
|
self.set_setting(SETTING_HOSTNAME, hostname)
|
|
}
|
|
|
|
/// Loads every signed record known for a network.
|
|
///
|
|
/// Records are returned as stored; the caller verifies them, because the
|
|
/// database is not a trust boundary — a restored backup or a copied file
|
|
/// could contain anything.
|
|
pub fn signed_records(&self, network_id: NetworkId) -> Result<Vec<SignedRecord>> {
|
|
let mut stmt = self
|
|
.conn
|
|
.prepare(
|
|
"SELECT author, version, body, signature FROM signed_records
|
|
WHERE network_id = ?1",
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?;
|
|
let rows = stmt
|
|
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
|
let author: Vec<u8> = row.get(0)?;
|
|
let version: i64 = row.get(1)?;
|
|
let body: Vec<u8> = row.get(2)?;
|
|
let signature: Vec<u8> = row.get(3)?;
|
|
Ok((author, version, body, signature))
|
|
})
|
|
.map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?;
|
|
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (author, version, body, signature) =
|
|
row.map_err(|err| Error::Storage(format!("cannot read a record row: {err}")))?;
|
|
let Ok(author) = <[u8; 32]>::try_from(author.as_slice()) else {
|
|
continue;
|
|
};
|
|
let Ok(body) = postcard::from_bytes(&body) else {
|
|
continue;
|
|
};
|
|
out.push(SignedRecord {
|
|
author,
|
|
network: *network_id.as_bytes(),
|
|
version: version as u64,
|
|
body,
|
|
signature,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Stores a record received from somebody else.
|
|
pub fn put_signed_record(&self, record: &SignedRecord) -> Result<()> {
|
|
let body = postcard::to_stdvec(&record.body)
|
|
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO signed_records (network_id, author, version, body, signature)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(network_id, author) DO UPDATE SET
|
|
version = excluded.version,
|
|
body = excluded.body,
|
|
signature = excluded.signature",
|
|
params![
|
|
record.network.as_slice(),
|
|
record.author.as_slice(),
|
|
record.version as i64,
|
|
body,
|
|
record.signature
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store a signed record: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Stores one of **our own** records and bumps our counter, atomically.
|
|
///
|
|
/// The model requires that a record and the author's own version counter
|
|
/// are committed together, and **before** the record is published, so a
|
|
/// crash can never leave us able to reuse a version number we already put
|
|
/// on the wire.
|
|
pub fn publish_own_record(&self, record: &SignedRecord) -> Result<()> {
|
|
let body = postcard::to_stdvec(&record.body)
|
|
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
|
|
let transaction = self
|
|
.conn
|
|
.unchecked_transaction()
|
|
.map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?;
|
|
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO signed_records (network_id, author, version, body, signature)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(network_id, author) DO UPDATE SET
|
|
version = excluded.version,
|
|
body = excluded.body,
|
|
signature = excluded.signature",
|
|
params![
|
|
record.network.as_slice(),
|
|
record.author.as_slice(),
|
|
record.version as i64,
|
|
body,
|
|
record.signature
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store our record: {err}")))?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO own_record_version (network_id, version) VALUES (?1, ?2)
|
|
ON CONFLICT(network_id) DO UPDATE SET
|
|
version = max(version, excluded.version)",
|
|
params![record.network.as_slice(), record.version as i64],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
|
|
|
|
transaction
|
|
.commit()
|
|
.map_err(|err| Error::Storage(format!("cannot commit our record: {err}")))
|
|
}
|
|
|
|
/// The highest version we have ever published for a network.
|
|
///
|
|
/// Monotonic even if our record is later replaced by a conflicting one,
|
|
/// so we never reuse a number.
|
|
pub fn own_record_version(&self, network_id: NetworkId) -> Result<u64> {
|
|
let version: Option<i64> = self
|
|
.conn
|
|
.query_row(
|
|
"SELECT version FROM own_record_version WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice()],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?;
|
|
Ok(version.unwrap_or(0).max(0) as u64)
|
|
}
|
|
|
|
/// Forgets every record of a network.
|
|
pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"DELETE FROM signed_records WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot clear signed records: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads an arbitrary setting.
|
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
|
self.conn
|
|
.query_row(
|
|
"SELECT value FROM settings WHERE key = ?1",
|
|
params![key],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| Error::Storage(format!("cannot read setting `{key}`: {err}")))
|
|
}
|
|
|
|
/// Writes an arbitrary setting.
|
|
pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
params![key, value],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot write setting `{key}`: {err}")))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Schema for the signed records described in [`crate::state`].
|
|
const SIGNED_RECORDS_SCHEMA: &str = "BEGIN;
|
|
CREATE TABLE IF NOT EXISTS signed_records (
|
|
network_id BLOB NOT NULL,
|
|
author BLOB NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
body BLOB NOT NULL,
|
|
signature BLOB NOT NULL,
|
|
PRIMARY KEY (network_id, author)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS own_record_version (
|
|
network_id BLOB PRIMARY KEY,
|
|
version INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 2;
|
|
COMMIT;";
|
|
|
|
/// Seconds since the Unix epoch, saturating at 0 before it.
|
|
pub(crate) fn now_unix() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|