The command line package is now `tsunagi` and builds the `tsng` executable, which the packages, the unit, the installers and the workflows install and run. The core library package becomes `tsunagi-core`, keeping the library name `tsunagi`, so no code changes `use tsunagi::...`. Directories follow (crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now `tsng ...`. Co-Authored-By: Claude <noreply@anthropic.com>
955 lines
38 KiB
Rust
955 lines
38 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::{RecordBody, SignedRecord};
|
|
|
|
/// Schema version written by this build.
|
|
pub const SCHEMA_VERSION: i64 = 6;
|
|
|
|
/// 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,
|
|
/// Local broadcast participation, enabled unless explicitly disabled.
|
|
pub broadcast: bool,
|
|
/// This agent offers itself as an exit node here. Off unless turned on.
|
|
pub exit_node: bool,
|
|
/// The member this device sends all its internet traffic through, if any.
|
|
pub exit_via: Option<[u8; 32]>,
|
|
}
|
|
|
|
/// 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();
|
|
// The database file is created on demand, so the directory holding
|
|
// it has to be too — with the same restricted permissions the agent
|
|
// would have given it, never looser.
|
|
if let Some(parent) = path.parent()
|
|
&& !parent.as_os_str().is_empty()
|
|
{
|
|
super::create_dir(parent)?;
|
|
}
|
|
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 2 -> 3: the record body gained a hostname, which changed
|
|
// the signing domain, and the version counter gained an author.
|
|
//
|
|
// The stored records are discarded rather than carried over. They
|
|
// were signed under a domain that no longer verifies, so keeping them
|
|
// would mean holding rows that every read has to reject — and one of
|
|
// those rejections could be mistaken for corruption. Each member
|
|
// re-publishes its claim on the next run, which is the one thing here
|
|
// that repairs itself.
|
|
if (2..3).contains(&found) {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
DROP TABLE IF EXISTS signed_records;
|
|
DROP TABLE IF EXISTS own_record_version;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
|
|
self.conn
|
|
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
|
|
}
|
|
|
|
// 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}")))?;
|
|
}
|
|
if found < 4 {
|
|
self.conn.execute_batch("BEGIN;
|
|
ALTER TABLE networks ADD COLUMN broadcast INTEGER NOT NULL DEFAULT 1 CHECK (broadcast IN (0,1));
|
|
PRAGMA user_version = 4;
|
|
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
|
}
|
|
if found < 5 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
|
network_id BLOB NOT NULL,
|
|
endpoint_id BLOB NOT NULL,
|
|
hostname TEXT NOT NULL,
|
|
PRIMARY KEY (network_id, endpoint_id)
|
|
);
|
|
PRAGMA user_version = 5;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
|
}
|
|
if found < 6 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1));
|
|
ALTER TABLE networks ADD COLUMN exit_via BLOB;
|
|
PRAGMA user_version = 6;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {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",
|
|
"peer_hostnames",
|
|
] {
|
|
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)
|
|
}
|
|
|
|
/// Replaces the device identity, giving up what the outgoing key held.
|
|
///
|
|
/// A device key is the author of every record this agent has signed, so
|
|
/// replacing it makes this a different member. The addresses and names
|
|
/// the old key claimed would otherwise stay reserved to a key nobody
|
|
/// holds, and nothing could ever free them — there is no way to sign on
|
|
/// another author's behalf, and by design there is no authority that
|
|
/// could overrule one.
|
|
///
|
|
/// So the outgoing key signs a release for every network on its way out.
|
|
/// That is the revocation: a positive statement, merged like any other,
|
|
/// which frees the address and the name for whoever wants them next.
|
|
///
|
|
/// All of it commits together. A crash part way through must not leave an
|
|
/// identity that has already been replaced beside releases that were
|
|
/// never written, because the old key would then be gone and unable to
|
|
/// sign them.
|
|
///
|
|
/// Returns the new identity and the networks a release was signed for.
|
|
pub fn rotate_device_identity(&self) -> Result<(DeviceIdentity, Vec<NetworkId>)> {
|
|
let outgoing = self.device_identity()?;
|
|
let networks = self.list_networks()?;
|
|
let replacement = DeviceIdentity::generate();
|
|
|
|
let transaction = self
|
|
.conn
|
|
.unchecked_transaction()
|
|
.map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?;
|
|
|
|
let mut released = Vec::new();
|
|
if let Some(outgoing) = &outgoing {
|
|
let author = outgoing.endpoint_id();
|
|
let signing = outgoing.signing_key();
|
|
for network in &networks {
|
|
let previous: Option<i64> = transaction
|
|
.query_row(
|
|
"SELECT version FROM own_record_version
|
|
WHERE network_id = ?1 AND author = ?2",
|
|
params![
|
|
network.network_id.as_bytes().as_slice(),
|
|
author.as_bytes().as_slice()
|
|
],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?;
|
|
// A key that never published anything has nothing to give up.
|
|
let Some(previous) = previous else { continue };
|
|
|
|
let version = (previous.max(0) as u64).saturating_add(1);
|
|
let record =
|
|
SignedRecord::sign(&signing, network.network_id, version, RecordBody::Release);
|
|
let body = postcard::to_stdvec(&record.body)
|
|
.map_err(|err| Error::Storage(format!("cannot encode a record body: {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 a release: {err}")))?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO own_record_version (network_id, author, version)
|
|
VALUES (?1, ?2, ?3)
|
|
ON CONFLICT(network_id, author) DO UPDATE SET
|
|
version = max(version, excluded.version)",
|
|
params![
|
|
record.network.as_slice(),
|
|
record.author.as_slice(),
|
|
record.version as i64
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
|
|
released.push(network.network_id);
|
|
}
|
|
}
|
|
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
secret_key = excluded.secret_key,
|
|
created_at = excluded.created_at",
|
|
params![replacement.secret_bytes().as_slice(), now_unix()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store the new identity: {err}")))?;
|
|
|
|
transaction
|
|
.commit()
|
|
.map_err(|err| Error::Storage(format!("cannot commit the new identity: {err}")))?;
|
|
Ok((replacement, released))
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
/// Saves local participation without changing identity, membership or auto-start.
|
|
pub fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
|
let changed = self
|
|
.conn
|
|
.execute(
|
|
"UPDATE networks SET broadcast = ?2 WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice(), enabled as i64],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update broadcast policy: {err}")))?;
|
|
if changed == 0 {
|
|
return Err(Error::NetworkUnknown(network_id));
|
|
}
|
|
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(())
|
|
}
|
|
|
|
/// Saves whether this agent offers itself as an exit node in a network.
|
|
pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
|
let changed = self
|
|
.conn
|
|
.execute(
|
|
"UPDATE networks SET exit_node = ?2 WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice(), enabled as i64],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?;
|
|
if changed == 0 {
|
|
return Err(Error::NetworkUnknown(network_id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Saves which member this device sends its internet traffic through.
|
|
///
|
|
/// One device has one default route, so choosing an exit node in one
|
|
/// network clears the choice in every other.
|
|
pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> {
|
|
let tx = self
|
|
.conn
|
|
.unchecked_transaction()
|
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
|
let changed = tx
|
|
.execute(
|
|
"UPDATE networks SET exit_via = ?2 WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
|
if changed == 0 {
|
|
return Err(Error::NetworkUnknown(network_id));
|
|
}
|
|
if via.is_some() {
|
|
tx.execute(
|
|
"UPDATE networks SET exit_via = NULL WHERE network_id != ?1",
|
|
params![network_id.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?;
|
|
}
|
|
tx.commit()
|
|
.map_err(|err| Error::Storage(format!("cannot update exit node: {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, broadcast, exit_node, exit_via 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,
|
|
row.get::<_, bool>(4)?,
|
|
row.get::<_, bool>(5)?,
|
|
row.get::<_, Option<Vec<u8>>>(6)?,
|
|
))
|
|
})
|
|
.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, broadcast, exit_node, exit_via) =
|
|
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,
|
|
broadcast,
|
|
exit_node,
|
|
exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()),
|
|
});
|
|
}
|
|
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, author, version)
|
|
VALUES (?1, ?2, ?3)
|
|
ON CONFLICT(network_id, author) DO UPDATE SET
|
|
version = max(version, excluded.version)",
|
|
params![
|
|
record.network.as_slice(),
|
|
record.author.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 an author has ever published for a network.
|
|
///
|
|
/// Monotonic even if the record is later replaced by a conflicting one,
|
|
/// so a number is never reused. Keyed by author as well: a replaced
|
|
/// device key is a different author and starts its own sequence, while
|
|
/// the outgoing one keeps its place so the release it signs on the way
|
|
/// out cannot collide with something it already published.
|
|
pub fn own_record_version(
|
|
&self,
|
|
network_id: NetworkId,
|
|
author: iroh::EndpointId,
|
|
) -> Result<u64> {
|
|
let version: Option<i64> = self
|
|
.conn
|
|
.query_row(
|
|
"SELECT version FROM own_record_version WHERE network_id = ?1 AND author = ?2",
|
|
params![
|
|
network_id.as_bytes().as_slice(),
|
|
author.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(())
|
|
}
|
|
|
|
/// Remembers the hostname a member last announced.
|
|
pub fn remember_peer_hostname(
|
|
&self,
|
|
network_id: NetworkId,
|
|
endpoint_id: &[u8; 32],
|
|
hostname: &str,
|
|
) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
|
VALUES (?1, ?2, ?3)
|
|
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
|
params![
|
|
network_id.as_bytes().as_slice(),
|
|
endpoint_id.as_slice(),
|
|
hostname
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The last hostname every member of a network announced.
|
|
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
|
let mut stmt = self
|
|
.conn
|
|
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
|
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
|
let rows = stmt
|
|
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
|
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
|
})
|
|
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (id, hostname) =
|
|
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
|
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
|
out.push((id, hostname));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Forgets every remembered hostname of a network.
|
|
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {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`].
|
|
///
|
|
/// The version counter is keyed by author as well as network. A device key
|
|
/// can be replaced, and the replacement is a different author: it must start
|
|
/// its own sequence rather than inherit one, and the outgoing author's last
|
|
/// version has to survive so its release record cannot collide with
|
|
/// something it already published.
|
|
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 NOT NULL,
|
|
author BLOB NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
PRIMARY KEY (network_id, author)
|
|
);
|
|
PRAGMA user_version = 3;
|
|
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)
|
|
}
|
|
#[cfg(test)]
|
|
mod broadcast_storage_tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
use super::*;
|
|
use crate::identity::NetworkKeys;
|
|
|
|
#[test]
|
|
fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store = StateStore::open(dir.path().join("state.sqlite")).unwrap();
|
|
let secret = NetworkSecret::from_bytes([5; 32]).unwrap();
|
|
let make = |name: &str| {
|
|
let name = NetworkName::new(name).unwrap();
|
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
id
|
|
};
|
|
let (a, b) = (make("exit-a"), make("exit-b"));
|
|
let listed = || store.list_networks().unwrap();
|
|
assert!(
|
|
listed()
|
|
.iter()
|
|
.all(|n| !n.exit_node && n.exit_via.is_none())
|
|
);
|
|
|
|
store.set_exit_node(a, true).unwrap();
|
|
store.set_exit_via(a, Some(&[7; 32])).unwrap();
|
|
store.set_exit_via(b, Some(&[8; 32])).unwrap();
|
|
let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap();
|
|
assert!(by_id(a).exit_node, "offering is per network and kept");
|
|
assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared");
|
|
assert_eq!(by_id(b).exit_via, Some([8; 32]));
|
|
|
|
store.set_exit_via(b, None).unwrap();
|
|
assert_eq!(by_id(b).exit_via, None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("state.sqlite");
|
|
let name = NetworkName::new("hostnames").unwrap();
|
|
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
|
let peer = [3u8; 32];
|
|
|
|
let store = StateStore::open(&path).unwrap();
|
|
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
|
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
|
drop(store);
|
|
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(
|
|
store.peer_hostnames(id).unwrap(),
|
|
vec![(peer, "laptop-2".to_string())]
|
|
);
|
|
store.forget_peer_hostnames(id).unwrap();
|
|
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("state.sqlite");
|
|
let name = NetworkName::new("migration-broadcast").unwrap();
|
|
let secret = NetworkSecret::from_bytes([7; 32]).unwrap();
|
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
|
let store = StateStore::open(&path).unwrap();
|
|
let identity = store
|
|
.load_or_create_device_identity()
|
|
.unwrap()
|
|
.endpoint_id();
|
|
store.upsert_network(id, &name, &secret, false).unwrap();
|
|
store.set_hostname("old-host").unwrap();
|
|
store
|
|
.conn
|
|
.execute_batch(
|
|
"ALTER TABLE networks DROP COLUMN broadcast; \
|
|
ALTER TABLE networks DROP COLUMN exit_node; \
|
|
ALTER TABLE networks DROP COLUMN exit_via; \
|
|
PRAGMA user_version=3;",
|
|
)
|
|
.unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
let networks = store.list_networks().unwrap();
|
|
assert_eq!(networks.len(), 1);
|
|
assert_eq!(networks[0].network_id, id);
|
|
assert!(!networks[0].auto_start);
|
|
assert!(networks[0].broadcast);
|
|
assert_eq!(store.hostname().unwrap().as_deref(), Some("old-host"));
|
|
assert_eq!(
|
|
store
|
|
.load_or_create_device_identity()
|
|
.unwrap()
|
|
.endpoint_id(),
|
|
identity
|
|
);
|
|
store.set_broadcast(id, false).unwrap();
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert!(
|
|
!store.list_networks().unwrap()[0].broadcast,
|
|
"rejoin and restart preserve opt-out"
|
|
);
|
|
store.remove_network(id).unwrap();
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
assert!(
|
|
store.list_networks().unwrap()[0].broadcast,
|
|
"forgotten network gets defaults"
|
|
);
|
|
}
|
|
}
|