Offline devices reserved names and IPv4 addresses indefinitely, leaving stale members in the device history. Give every authenticated network participant the same right to retire another device's observed signed claim without an owner, quorum, permanent identity ban or secret change. Add a separately signed retirement containing the target's original signed claim as proof. Verify both signatures and network scopes, retain the strongest proved cutoff per target with deterministic ties, and suppress claim versions at or below it in membership, hostname ownership and IPv4 allocation. Keep the old signed records and range agreement, and retain markers after a newer claim returns so old replicas cannot resurrect obsolete reservations. A returning device signs a newer claim, allocates against current holders and resets its previous hostname priority rather than taking a reused name with an old time. Commit local retirement and the requester's author counter atomically before announcing it; receivers persist proofs before applying them. Bound history to 1024 targets by default, reject additional targets without evicting protection, and treat corrupt or over-limit mandatory storage as an explicit error. Repeat bounded marker batches with a per-session rotating cursor so queue pressure cannot indefinitely starve later markers. Exclude retired claims and cached hints from history-derived discovery without preventing a fresh authenticated device from returning. Expose Agent::forget_node through the shared local control API. CLI command tsng network forget-node accepts exact network names, full ids or unique id prefixes, and exact node hostnames, ids, prefixes or overlay IPv4 addresses. Ambiguous selections change nothing. Recheck the observed version and refuse self, online nodes, the selected exit node and absent or changed claims. Add Forget with an identity confirmation to offline tray device rows. Widen Show devices to 1440x640 with an 800x400 minimum to fit its contents. Control ALPN/version advances to tsunagi/ctrl/7 and local IPC to 21; upgrade all peers and restart the agent with matching CLI/tray clients. Data ALPN 5, network identity derivation, handshake encoding and existing record signing bytes remain unchanged. SQLite schema 9 adds retirement storage while retaining identity, secrets, settings and signed claims. Document behavior and limits. Validation on Linux through nix develop: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets: 519 passed, 2 existing ignored tests; inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP were unset - git diff --check and the built CLI forget-node help Tests use real iroh authentication and SQLite. Cover restart, stale-replica recovery, equal rights and network isolation, hostname reuse and return, twelve marker batches through a two-message queue, malformed signatures and proofs, invented future cutoffs, deterministic merge, capacity, migration and injected counter-write rollback, selectors and cleanup through two real CLI processes. Earlier iterations caught a private test API, a collapsible tray conditional and a crash-based CLI test waiting for QUIC's idle timeout; corrected before the final successful suite. Manual tray rendering, macOS/Windows, privileged TUNs and public-network traversal were not run. The requested RC tag is v0.1.0-rc.31; no push was requested.
1336 lines
53 KiB
Rust
1336 lines
53 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::{MAX_RETIREMENT_LEN, RecordBody, SignedRecord, SignedRetirement};
|
|
|
|
/// Schema version written by this build.
|
|
pub const SCHEMA_VERSION: i64 = 9;
|
|
|
|
/// 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 {
|
|
/// Ordered local protocol override; absent inherits the agent's defaults.
|
|
pub protocols: Option<Vec<String>>,
|
|
/// 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}")))?;
|
|
}
|
|
// Migration 6 -> 7: a claim gained the time its hostname was first
|
|
// claimed, which changed the signing domain. Records signed before
|
|
// no longer verify, so they are discarded rather than kept as rows
|
|
// every read has to reject; each member re-publishes its own claim on
|
|
// its next run and the others arrive again from their authors. The
|
|
// version counter is kept, so nothing is ever published twice at one
|
|
// version.
|
|
if found < 7 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
DELETE FROM signed_records;
|
|
PRAGMA user_version = 7;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 7: {err}")))?;
|
|
}
|
|
if found < 8 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
ALTER TABLE networks ADD COLUMN protocols TEXT;
|
|
PRAGMA user_version = 8;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 8: {err}")))?;
|
|
}
|
|
if found < 9 {
|
|
self.conn
|
|
.execute_batch(
|
|
"BEGIN;
|
|
CREATE TABLE IF NOT EXISTS retirements (
|
|
network_id BLOB NOT NULL, target BLOB NOT NULL, data BLOB NOT NULL,
|
|
PRIMARY KEY (network_id, target)
|
|
);
|
|
PRAGMA user_version = 9;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| self.corrupt(format!("cannot migrate schema to 9: {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",
|
|
"retirements",
|
|
] {
|
|
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<()> {
|
|
let changed = 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}")))?;
|
|
if changed == 0 {
|
|
return Err(Error::NetworkUnknown(network_id));
|
|
}
|
|
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(())
|
|
}
|
|
|
|
/// Saves a local ordered protocol selection without changing membership.
|
|
pub fn set_protocols(&self, network_id: NetworkId, protocols: &[String]) -> Result<()> {
|
|
crate::dataplane::validate_protocols(
|
|
protocols,
|
|
crate::config::Limits::default().max_capabilities,
|
|
)?;
|
|
let changed = self
|
|
.conn
|
|
.execute(
|
|
"UPDATE networks SET protocols = ?2 WHERE network_id = ?1",
|
|
params![network_id.as_bytes().as_slice(), protocols.join(",")],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot update protocols: {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, protocols 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)?,
|
|
row.get::<_, Option<String>>(7)?,
|
|
))
|
|
})
|
|
.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, protocols) =
|
|
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
|
let protocols = protocols
|
|
.map(|text| {
|
|
let max = crate::config::Limits::default().max_capabilities;
|
|
if text.len() > max * (crate::dataplane::MAX_PROTOCOL_ID_LEN + 1) {
|
|
return Err(self.corrupt("stored protocol selection is too long"));
|
|
}
|
|
let names = if text.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
text.split(',').map(str::to_owned).collect()
|
|
};
|
|
crate::dataplane::validate_protocols(&names, max)
|
|
.map_err(|err| self.corrupt(err))?;
|
|
Ok(names)
|
|
})
|
|
.transpose()?;
|
|
let id: [u8; 32] = id
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| self.corrupt("stored network id is not 32 bytes"))?;
|
|
out.push(StoredNetwork {
|
|
protocols,
|
|
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)
|
|
}
|
|
|
|
/// Loads durable cleanup markers without allocating oversized stored blobs.
|
|
pub fn retirements(&self, network: NetworkId, max: usize) -> Result<Vec<SignedRetirement>> {
|
|
let mut stmt = self
|
|
.conn
|
|
.prepare(
|
|
"SELECT CASE WHEN length(data) <= ?2 THEN data ELSE NULL END
|
|
FROM retirements WHERE network_id = ?1 ORDER BY target",
|
|
)
|
|
.map_err(|err| self.corrupt(err))?;
|
|
let rows = stmt
|
|
.query_map(
|
|
params![network.as_bytes().as_slice(), MAX_RETIREMENT_LEN as i64],
|
|
|row| row.get::<_, Option<Vec<u8>>>(0),
|
|
)
|
|
.map_err(|err| self.corrupt(err))?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
if out.len() >= max {
|
|
return Err(Error::Storage(
|
|
"stored retirements exceed the configured limit".into(),
|
|
));
|
|
}
|
|
let bytes = row
|
|
.map_err(|err| self.corrupt(err))?
|
|
.ok_or_else(|| self.corrupt("oversized retirement"))?;
|
|
let marker: SignedRetirement =
|
|
postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?;
|
|
marker.verify(network).map_err(|err| self.corrupt(err))?;
|
|
out.push(marker);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Persists a strongest verified marker received from an authenticated session.
|
|
pub fn put_retirement(&self, marker: &SignedRetirement) -> Result<()> {
|
|
let network = NetworkId::from_bytes(marker.network);
|
|
marker
|
|
.verify(network)
|
|
.map_err(|err| Error::Storage(err.to_string()))?;
|
|
let old: Option<Option<Vec<u8>>> = self.conn.query_row(
|
|
"SELECT CASE WHEN length(data) <= ?3 THEN data ELSE NULL END FROM retirements WHERE network_id = ?1 AND target = ?2",
|
|
params![marker.network.as_slice(), marker.target.author.as_slice(), MAX_RETIREMENT_LEN as i64], |row| row.get(0))
|
|
.optional().map_err(|err| self.corrupt(err))?;
|
|
if let Some(bytes) = old {
|
|
let bytes = bytes.ok_or_else(|| self.corrupt("oversized retirement"))?;
|
|
let old: SignedRetirement =
|
|
postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?;
|
|
old.verify(network).map_err(|err| self.corrupt(err))?;
|
|
if !marker.supersedes(&old) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
let bytes = postcard::to_stdvec(marker).map_err(|err| Error::Storage(err.to_string()))?;
|
|
if bytes.len() > MAX_RETIREMENT_LEN {
|
|
return Err(Error::Storage(
|
|
"retirement exceeds its encoded bound".into(),
|
|
));
|
|
}
|
|
self.conn
|
|
.execute(
|
|
"INSERT INTO retirements (network_id, target, data) VALUES (?1, ?2, ?3)
|
|
ON CONFLICT(network_id, target) DO UPDATE SET data = excluded.data",
|
|
params![
|
|
marker.network.as_slice(),
|
|
marker.target.author.as_slice(),
|
|
bytes
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store retirement: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Commits our signed cleanup marker and author counter in one transaction.
|
|
pub fn publish_own_retirement(&self, marker: &SignedRetirement) -> Result<()> {
|
|
let transaction = self
|
|
.conn
|
|
.unchecked_transaction()
|
|
.map_err(|err| Error::Storage(err.to_string()))?;
|
|
self.put_retirement(marker)?;
|
|
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![
|
|
marker.network.as_slice(),
|
|
marker.author.as_slice(),
|
|
marker.version as i64
|
|
],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot store retirement counter: {err}")))?;
|
|
transaction
|
|
.commit()
|
|
.map_err(|err| Error::Storage(format!("cannot commit retirement: {err}")))
|
|
}
|
|
|
|
/// Drops a forgotten network's local cleanup proofs.
|
|
pub fn forget_retirements(&self, network: NetworkId) -> Result<()> {
|
|
self.conn
|
|
.execute(
|
|
"DELETE FROM retirements WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(err.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
/// Atomically enables a network and puts it first in the startup order.
|
|
pub fn prefer_network(&self, network: NetworkId) -> Result<()> {
|
|
let configured = self.list_networks()?;
|
|
if !configured.iter().any(|n| n.network_id == network) {
|
|
return Err(Error::NetworkUnknown(network));
|
|
}
|
|
let existing = self.get_setting("network_start_order")?.unwrap_or_default();
|
|
let mut order = vec![network];
|
|
for word in existing.split(',') {
|
|
if let Ok(id) = word.parse::<NetworkId>()
|
|
&& configured.iter().any(|n| n.network_id == id)
|
|
&& !order.contains(&id)
|
|
{
|
|
order.push(id);
|
|
}
|
|
}
|
|
let value = order
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let transaction = self
|
|
.conn
|
|
.unchecked_transaction()
|
|
.map_err(|err| Error::Storage(format!("cannot begin network preference: {err}")))?;
|
|
transaction
|
|
.execute(
|
|
"UPDATE networks SET auto_start = 1 WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| Error::Storage(format!("cannot enable network: {err}")))?;
|
|
transaction.execute("INSERT INTO settings (key, value) VALUES ('network_start_order', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![value])
|
|
.map_err(|err| Error::Storage(format!("cannot store network preference: {err}")))?;
|
|
transaction
|
|
.commit()
|
|
.map_err(|err| Error::Storage(format!("cannot commit network preference: {err}")))
|
|
}
|
|
|
|
/// 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 retirement_migration_and_atomic_counter_commit_preserve_existing_state() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("state.sqlite");
|
|
let store = StateStore::open(&path).unwrap();
|
|
let identity = store.load_or_create_device_identity().unwrap();
|
|
let name = NetworkName::new("retirement-migration").unwrap();
|
|
let secret = NetworkSecret::from_bytes([6; 32]).unwrap();
|
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
store.set_protocols(id, &["awg".into()]).unwrap();
|
|
let own = SignedRecord::sign(&identity.signing_key(), id, 8, RecordBody::Release);
|
|
store.publish_own_record(&own).unwrap();
|
|
store
|
|
.conn
|
|
.execute_batch("DROP TABLE retirements; PRAGMA user_version = 8;")
|
|
.unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(
|
|
store.device_identity().unwrap().unwrap().endpoint_id(),
|
|
identity.endpoint_id()
|
|
);
|
|
assert_eq!(
|
|
store.list_networks().unwrap()[0].protocols,
|
|
Some(vec!["awg".into()])
|
|
);
|
|
assert_eq!(store.signed_records(id).unwrap(), vec![own]);
|
|
let target_key = iroh::SecretKey::generate();
|
|
let target = SignedRecord::sign(
|
|
&target_key,
|
|
id,
|
|
3,
|
|
RecordBody::Claim {
|
|
address: Some("10.13.37.2".parse().unwrap()),
|
|
range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
|
hostname: Some("old-device".into()),
|
|
hostname_since: Some(123),
|
|
},
|
|
);
|
|
let marker = SignedRetirement::sign(&identity.signing_key(), id, 9, target);
|
|
store
|
|
.conn
|
|
.execute_batch(
|
|
"CREATE TRIGGER fail_retirement_counter BEFORE INSERT ON own_record_version
|
|
WHEN NEW.version = 9 BEGIN SELECT RAISE(ABORT, 'counter write refused'); END;",
|
|
)
|
|
.unwrap();
|
|
assert!(store.publish_own_retirement(&marker).is_err());
|
|
assert!(
|
|
store.retirements(id, 16).unwrap().is_empty(),
|
|
"marker must roll back with counter failure"
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.own_record_version(id, identity.endpoint_id())
|
|
.unwrap(),
|
|
8
|
|
);
|
|
store
|
|
.conn
|
|
.execute_batch("DROP TRIGGER fail_retirement_counter;")
|
|
.unwrap();
|
|
store.publish_own_retirement(&marker).unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(store.retirements(id, 16).unwrap(), vec![marker.clone()]);
|
|
assert_eq!(
|
|
store
|
|
.own_record_version(id, identity.endpoint_id())
|
|
.unwrap(),
|
|
9
|
|
);
|
|
assert!(
|
|
store.retirements(id, 0).is_err(),
|
|
"capacity changes must not silently drop markers"
|
|
);
|
|
store
|
|
.conn
|
|
.execute(
|
|
"UPDATE retirements SET data = ?1",
|
|
params![vec![0u8; MAX_RETIREMENT_LEN + 1]],
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
store.retirements(id, 16),
|
|
Err(Error::StateCorrupted { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn v7_migration_keeps_signed_state_and_protocol_overrides_are_local_and_durable() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("state.sqlite");
|
|
let store = StateStore::open(&path).unwrap();
|
|
let identity = store.load_or_create_device_identity().unwrap();
|
|
let name = NetworkName::new("protocol-migration").unwrap();
|
|
let secret = NetworkSecret::from_bytes([4; 32]).unwrap();
|
|
let id = NetworkKeys::derive(&name, &secret).network_id();
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
store.set_broadcast(id, false).unwrap();
|
|
let record = SignedRecord::sign(&identity.signing_key(), id, 17, RecordBody::Release);
|
|
store.publish_own_record(&record).unwrap();
|
|
store
|
|
.conn
|
|
.execute_batch("ALTER TABLE networks DROP COLUMN protocols; PRAGMA user_version=7;")
|
|
.unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(
|
|
store
|
|
.load_or_create_device_identity()
|
|
.unwrap()
|
|
.endpoint_id(),
|
|
identity.endpoint_id()
|
|
);
|
|
assert_eq!(store.signed_records(id).unwrap(), vec![record]);
|
|
let saved = store.list_networks().unwrap();
|
|
assert!(saved[0].protocols.is_none());
|
|
assert!(!saved[0].broadcast);
|
|
assert!(saved[0].auto_start);
|
|
assert_eq!(saved[0].secret, secret);
|
|
let chosen = vec!["awg".into(), "wg-quic".into()];
|
|
store.set_protocols(id, &chosen).unwrap();
|
|
assert!(
|
|
store
|
|
.set_protocols(id, &["awg".into(), "awg".into()])
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
store
|
|
.set_protocols(NetworkId::from_bytes([1; 32]), &[])
|
|
.is_err()
|
|
);
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(store.list_networks().unwrap()[0].protocols, Some(chosen));
|
|
store.upsert_network(id, &name, &secret, true).unwrap();
|
|
store.set_protocols(id, &[]).unwrap();
|
|
drop(store);
|
|
let store = StateStore::open(&path).unwrap();
|
|
assert_eq!(store.list_networks().unwrap()[0].protocols, Some(vec![]));
|
|
store
|
|
.conn
|
|
.execute("UPDATE networks SET protocols = 'awg,awg'", [])
|
|
.unwrap();
|
|
assert!(matches!(
|
|
store.list_networks(),
|
|
Err(Error::StateCorrupted { .. })
|
|
));
|
|
}
|
|
|
|
#[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; \
|
|
ALTER TABLE networks DROP COLUMN protocols; \
|
|
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"
|
|
);
|
|
}
|
|
}
|