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>
526 lines
18 KiB
Rust
526 lines
18 KiB
Rust
//! Persistence, split into mandatory state and a disposable cache.
|
|
//!
|
|
//! | store | contents | on damage |
|
|
//! |----------------|--------------------------------------------------------|-----------|
|
|
//! | `state.sqlite` | device identity, network configuration, hostname | hard error |
|
|
//! | `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
|
//!
|
|
//! Both files are created with owner-only permissions where the platform
|
|
//! supports it. The state directory additionally carries an ownership lock, see
|
|
//! [`DirectoryLock`].
|
|
//!
|
|
//! SQLite is synchronous. Every call that touches a database therefore runs on
|
|
//! a blocking pool via [`tokio::task::spawn_blocking`], and no database lock is
|
|
//! ever held across a network `await`.
|
|
|
|
mod cache;
|
|
mod lock;
|
|
mod state;
|
|
|
|
pub use cache::{AddressHint, CacheOutcome, CacheStore};
|
|
pub use lock::DirectoryLock;
|
|
pub use state::{SCHEMA_VERSION, StateStore, StoredNetwork};
|
|
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use crate::config::StoragePaths;
|
|
use crate::error::{Error, Result};
|
|
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
|
use crate::state::SignedRecord;
|
|
|
|
/// Applies the pragmas both stores share.
|
|
fn apply_common_pragmas(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
|
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Restricts a file to the current user where the platform supports it.
|
|
#[cfg(unix)]
|
|
fn restrict_permissions(file: &std::fs::File, path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o600);
|
|
file.set_permissions(perms).map_err(|source| Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
})
|
|
}
|
|
|
|
/// On Windows, files inherit the parent directory's ACL, which for the
|
|
/// per-user application data directory is already restricted to that user.
|
|
#[cfg(not(unix))]
|
|
fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Restricts a file to the user that owns it.
|
|
///
|
|
/// Part of what the system level offers a protocol: a plugin keeping keys of
|
|
/// its own on disk has the same obligation as the agent, and should not have
|
|
/// to work out the platform details again to meet it.
|
|
#[cfg(unix)]
|
|
pub fn restrict_path_permissions(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
|
Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Restricts a file to the user that owns it. A no-op off Unix.
|
|
#[cfg(not(unix))]
|
|
pub fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Restricts a directory to the user that owns it.
|
|
#[cfg(unix)]
|
|
pub fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
|
|
Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Restricts a directory to the user that owns it. A no-op off Unix.
|
|
#[cfg(not(unix))]
|
|
pub fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Creates a directory and every parent, restricted to this user.
|
|
pub fn create_dir(path: &Path) -> Result<()> {
|
|
std::fs::create_dir_all(path).map_err(|source| Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
restrict_dir_permissions(path)
|
|
}
|
|
|
|
/// Async facade over both stores, holding the directory ownership lock.
|
|
///
|
|
/// Cloning shares the same underlying connections and the same lock.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Storage {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Inner {
|
|
state: Mutex<StateStore>,
|
|
cache: Mutex<Option<CacheStore>>,
|
|
cache_outcome: CacheOutcome,
|
|
paths: StoragePaths,
|
|
lock: Mutex<Option<DirectoryLock>>,
|
|
}
|
|
|
|
impl Storage {
|
|
/// Opens both stores and takes the ownership lock on the state directory.
|
|
///
|
|
/// Fails with [`Error::StateLocked`] if another live agent owns the state
|
|
/// directory, and with [`Error::StateCorrupted`] if the mandatory state is
|
|
/// unusable. A broken cache is silently discarded and reported through
|
|
/// [`Storage::cache_outcome`].
|
|
pub fn open(paths: &StoragePaths) -> Result<Self> {
|
|
create_dir(&paths.state_dir)?;
|
|
create_dir(&paths.cache_dir)?;
|
|
|
|
let lock = DirectoryLock::acquire(paths.lock_file())?;
|
|
let state = StateStore::open(paths.state_db())?;
|
|
let (cache, cache_outcome) = CacheStore::open_or_reset(paths.cache_db())?;
|
|
|
|
Ok(Self {
|
|
inner: Arc::new(Inner {
|
|
state: Mutex::new(state),
|
|
cache: Mutex::new(Some(cache)),
|
|
cache_outcome,
|
|
paths: paths.clone(),
|
|
lock: Mutex::new(Some(lock)),
|
|
}),
|
|
})
|
|
}
|
|
|
|
/// What happened to the cache when the agent started.
|
|
pub fn cache_outcome(&self) -> &CacheOutcome {
|
|
&self.inner.cache_outcome
|
|
}
|
|
|
|
/// The configured paths.
|
|
pub fn paths(&self) -> &StoragePaths {
|
|
&self.inner.paths
|
|
}
|
|
|
|
fn lock_state(&self) -> MutexGuard<'_, StateStore> {
|
|
match self.inner.state.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
fn lock_cache(&self) -> MutexGuard<'_, Option<CacheStore>> {
|
|
match self.inner.cache.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
/// Runs a closure against the mandatory state store on the blocking pool.
|
|
async fn with_state<T, F>(&self, f: F) -> Result<T>
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce(&StateStore) -> Result<T> + Send + 'static,
|
|
{
|
|
let inner = Arc::clone(&self.inner);
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = match inner.state.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
f(&guard)
|
|
})
|
|
.await
|
|
.map_err(|err| Error::Storage(format!("state task failed: {err}")))?
|
|
}
|
|
|
|
/// Runs a closure against the cache, tolerating an unavailable cache.
|
|
///
|
|
/// If the cache has been disabled because it misbehaved, the closure is
|
|
/// skipped and `default` is returned.
|
|
async fn with_cache<T, F>(&self, default: T, f: F) -> T
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce(&CacheStore) -> Result<T> + Send + 'static,
|
|
{
|
|
let inner = Arc::clone(&self.inner);
|
|
let joined = tokio::task::spawn_blocking(move || {
|
|
let guard = match inner.cache.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
match guard.as_ref() {
|
|
Some(cache) => f(cache),
|
|
None => Err(Error::Storage("cache is unavailable".into())),
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match joined {
|
|
Ok(Ok(value)) => value,
|
|
Ok(Err(err)) => {
|
|
tracing::debug!(%err, "cache operation failed; continuing without it");
|
|
default
|
|
}
|
|
Err(err) => {
|
|
tracing::debug!(%err, "cache task failed; continuing without it");
|
|
default
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Loads or creates the persistent device identity.
|
|
pub async fn device_identity(&self) -> Result<DeviceIdentity> {
|
|
self.with_state(|state| state.load_or_create_device_identity())
|
|
.await
|
|
}
|
|
|
|
/// Lists configured networks.
|
|
pub async fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
|
self.with_state(|state| state.list_networks()).await
|
|
}
|
|
|
|
/// Stores or updates a network configuration.
|
|
pub async fn upsert_network(
|
|
&self,
|
|
network_id: NetworkId,
|
|
name: NetworkName,
|
|
secret: NetworkSecret,
|
|
auto_start: bool,
|
|
) -> Result<()> {
|
|
self.with_state(move |state| state.upsert_network(network_id, &name, &secret, auto_start))
|
|
.await
|
|
}
|
|
|
|
/// Persists local broadcast participation for one configured network.
|
|
pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
|
self.with_state(move |state| state.set_broadcast(network_id, enabled))
|
|
.await
|
|
}
|
|
|
|
/// Persists whether this agent offers itself as an exit node in a network.
|
|
pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> {
|
|
self.with_state(move |state| state.set_exit_node(network_id, enabled))
|
|
.await
|
|
}
|
|
|
|
/// Persists the member this device sends its internet traffic through.
|
|
pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> {
|
|
self.with_state(move |state| state.set_exit_via(network_id, via.as_ref()))
|
|
.await
|
|
}
|
|
|
|
/// Updates the auto-start flag of a network.
|
|
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
|
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
|
.await
|
|
}
|
|
|
|
/// Removes a network configuration and its cached hints.
|
|
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
|
self.with_state(move |state| {
|
|
state.remove_network(network_id)?;
|
|
state.forget_signed_records(network_id)?;
|
|
state.forget_peer_hostnames(network_id)
|
|
})
|
|
.await?;
|
|
self.with_cache((), move |cache| cache.forget_network(network_id))
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads the stored hostname.
|
|
pub async fn hostname(&self) -> Result<Option<String>> {
|
|
self.with_state(|state| state.hostname()).await
|
|
}
|
|
|
|
/// Writes the stored hostname.
|
|
pub async fn set_hostname(&self, hostname: String) -> Result<()> {
|
|
self.with_state(move |state| state.set_hostname(&hostname))
|
|
.await
|
|
}
|
|
|
|
/// Remembers the hostname a member announced, so it can still be named
|
|
/// once it is away.
|
|
pub async fn remember_peer_hostname(
|
|
&self,
|
|
network_id: NetworkId,
|
|
endpoint_id: [u8; 32],
|
|
hostname: String,
|
|
) -> Result<()> {
|
|
self.with_state(move |state| {
|
|
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// The hostnames members of a network last announced.
|
|
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
|
self.with_state(move |state| state.peer_hostnames(network_id))
|
|
.await
|
|
}
|
|
|
|
/// Records an address hint. Failures are non-fatal.
|
|
pub async fn record_hint(
|
|
&self,
|
|
network_id: NetworkId,
|
|
endpoint_id: [u8; 32],
|
|
addr: String,
|
|
max_per_peer: usize,
|
|
) {
|
|
self.with_cache((), move |cache| {
|
|
cache.record_hint(network_id, &endpoint_id, &addr, max_per_peer)
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Loads every signed record known for a network.
|
|
pub async fn signed_records(&self, network_id: NetworkId) -> Result<Vec<SignedRecord>> {
|
|
self.with_state(move |state| state.signed_records(network_id))
|
|
.await
|
|
}
|
|
|
|
/// Stores a record received from another replica.
|
|
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
|
|
self.with_state(move |state| state.put_signed_record(&record))
|
|
.await
|
|
}
|
|
|
|
/// Stores one of this agent's own records and bumps its counter in one
|
|
/// transaction, which must happen before the record is announced.
|
|
pub async fn publish_own_record(&self, record: SignedRecord) -> Result<()> {
|
|
self.with_state(move |state| state.publish_own_record(&record))
|
|
.await
|
|
}
|
|
|
|
/// The highest version this agent has ever published for a network.
|
|
pub async fn own_record_version(
|
|
&self,
|
|
network_id: NetworkId,
|
|
author: iroh::EndpointId,
|
|
) -> Result<u64> {
|
|
self.with_state(move |state| state.own_record_version(network_id, author))
|
|
.await
|
|
}
|
|
|
|
/// Reads cached address hints. Returns an empty list if the cache is gone.
|
|
pub async fn hints_for_network(&self, network_id: NetworkId) -> Vec<AddressHint> {
|
|
self.with_cache(Vec::new(), move |cache| cache.hints_for_network(network_id))
|
|
.await
|
|
}
|
|
|
|
/// Synchronously reads the hostname. Used only during startup.
|
|
pub(crate) fn hostname_blocking(&self) -> Result<Option<String>> {
|
|
self.lock_state().hostname()
|
|
}
|
|
|
|
/// Whether the cache is currently usable.
|
|
pub fn cache_healthy(&self) -> bool {
|
|
self.lock_cache().is_some()
|
|
}
|
|
|
|
/// Releases the state directory ownership lock.
|
|
///
|
|
/// Called by [`crate::Agent::shutdown`] so that a cleanly stopped agent
|
|
/// leaves its directory immediately claimable by another instance. The
|
|
/// databases stay open and readable, but this handle no longer owns the
|
|
/// directory and must not be used to write after this point.
|
|
pub fn release_ownership_lock(&self) {
|
|
let mut guard = match self.inner.lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
guard.take();
|
|
}
|
|
}
|
|
|
|
/// What a wipe covers: every entry in the two directories.
|
|
///
|
|
/// Reported before anything is removed, and again afterwards, because a
|
|
/// command whose whole job is to destroy state has to be able to say exactly
|
|
/// what it is about to destroy.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct WipePlan {
|
|
/// Entries in the state directory, which is the mandatory state and
|
|
/// anything a plugin keeps beside it.
|
|
pub state: Vec<std::path::PathBuf>,
|
|
/// Entries in the cache directory.
|
|
pub cache: Vec<std::path::PathBuf>,
|
|
}
|
|
|
|
impl WipePlan {
|
|
/// Whether there is anything to remove at all.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.state.is_empty() && self.cache.is_empty()
|
|
}
|
|
|
|
/// Every entry, state first.
|
|
pub fn entries(&self) -> impl Iterator<Item = &std::path::Path> {
|
|
self.state
|
|
.iter()
|
|
.chain(self.cache.iter())
|
|
.map(std::path::PathBuf::as_path)
|
|
}
|
|
}
|
|
|
|
/// Lists what [`wipe`] would remove, without removing anything.
|
|
pub fn wipe_plan(paths: &StoragePaths) -> Result<WipePlan> {
|
|
Ok(WipePlan {
|
|
state: removable(&paths.state_dir, &paths.state_db())?,
|
|
cache: removable(&paths.cache_dir, &paths.cache_db())?,
|
|
})
|
|
}
|
|
|
|
/// Removes everything in both directories, leaving this device a stranger.
|
|
///
|
|
/// The device identity, every network it belongs to, every signed record and
|
|
/// everything a plugin kept beside them. The next start generates a new
|
|
/// identity and knows nothing, which is the point: after this there is no
|
|
/// membership left to be surprised by.
|
|
///
|
|
/// What it is **not** is a goodbye. Other members keep the claims this device
|
|
/// signed, because signed state has no expiry and there is nobody left here
|
|
/// to sign a release. Leaving each network first with
|
|
/// [`crate::Agent::leave_network`] is what frees the address and the name.
|
|
///
|
|
/// Refuses while an agent owns the state directory, and refuses a directory
|
|
/// with no `state.sqlite` in it — a wipe pointed at the wrong place by a
|
|
/// mistyped `--state-dir` would otherwise remove somebody's documents.
|
|
pub fn wipe(paths: &StoragePaths) -> Result<WipePlan> {
|
|
let plan = wipe_plan(paths)?;
|
|
// Held across the removal, so an agent cannot start into a directory
|
|
// that is half gone. The lock file is itself removed at the end: on
|
|
// Unix the lock lives on the open handle, not on the name.
|
|
let lock = DirectoryLock::acquire(paths.lock_file())?;
|
|
|
|
for entry in plan.entries() {
|
|
remove_entry(entry)?;
|
|
}
|
|
drop(lock);
|
|
// Created by acquiring the lock a moment ago, so it is ours to remove
|
|
// and nothing is left behind claiming the directory.
|
|
let _ = std::fs::remove_file(paths.lock_file());
|
|
Ok(plan)
|
|
}
|
|
|
|
/// Every entry of `dir`, once `marker` proves it is one of ours.
|
|
///
|
|
/// An empty or missing directory is nothing to remove rather than an error:
|
|
/// wiping twice must be as ordinary as wiping once.
|
|
fn removable(dir: &Path, marker: &Path) -> Result<Vec<std::path::PathBuf>> {
|
|
if !dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut entries = Vec::new();
|
|
let read = std::fs::read_dir(dir).map_err(|source| Error::Io {
|
|
path: dir.to_path_buf(),
|
|
source,
|
|
})?;
|
|
for entry in read {
|
|
let entry = entry.map_err(|source| Error::Io {
|
|
path: dir.to_path_buf(),
|
|
source,
|
|
})?;
|
|
entries.push(entry.path());
|
|
}
|
|
if entries.is_empty() {
|
|
return Ok(entries);
|
|
}
|
|
|
|
// The marker is what tells a state directory from a directory that
|
|
// merely got named as one. The lock file counts too: an agent that
|
|
// crashed before writing anything still leaves that.
|
|
let ours = marker.exists() || dir.join("state.lock").exists();
|
|
if !ours {
|
|
return Err(Error::Storage(format!(
|
|
"{} does not look like a tsunagi directory: no {} in it. Nothing was removed.",
|
|
dir.display(),
|
|
marker.file_name().map_or_else(
|
|
|| marker.display().to_string(),
|
|
|name| name.to_string_lossy().into_owned()
|
|
)
|
|
)));
|
|
}
|
|
entries.sort();
|
|
Ok(entries)
|
|
}
|
|
|
|
fn remove_entry(path: &Path) -> Result<()> {
|
|
let metadata = match std::fs::symlink_metadata(path) {
|
|
Ok(metadata) => metadata,
|
|
// Gone between the plan and the removal is the outcome asked for.
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
Err(source) => {
|
|
return Err(Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
let removed = if metadata.is_dir() {
|
|
std::fs::remove_dir_all(path)
|
|
} else {
|
|
std::fs::remove_file(path)
|
|
};
|
|
removed.map_err(|source| Error::Io {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
})
|
|
}
|