diff --git a/iroh/src/client/authors.rs b/iroh/src/client/authors.rs index 24773c3a55..eac2a55496 100644 --- a/iroh/src/client/authors.rs +++ b/iroh/src/client/authors.rs @@ -6,8 +6,8 @@ use iroh_docs::{Author, AuthorId}; use quic_rpc::{RpcClient, ServiceConnection}; use crate::rpc_protocol::{ - AuthorCreateRequest, AuthorGetDefaultRequest, AuthorDeleteRequest, AuthorExportRequest, - AuthorImportRequest, AuthorListRequest, RpcService, + AuthorCreateRequest, AuthorDeleteRequest, AuthorExportRequest, AuthorGetDefaultRequest, + AuthorImportRequest, AuthorListRequest, AuthorSetDefaultRequest, RpcService, }; use super::flatten; @@ -38,13 +38,25 @@ where /// On persistent nodes, the author is created on first start and its public key is saved /// in the data directory. /// - /// The default author can neither be changed nor deleted. If you need more semantics around - /// authors than a single author per node, use [`Self::create`]. + /// The default author can be set with [`Self::set_default`]. pub async fn default(&self) -> Result { let res = self.rpc.rpc(AuthorGetDefaultRequest).await?; Ok(res.author_id) } + /// Set the node-wide default author. + /// + /// If the author does not exist, an error is returned. + /// + /// This is a noop on memory nodes. On peristent node, the author id will be saved to a file in + /// the data directory, and reloaded after a node restart. + pub async fn set_default(&self, author_id: AuthorId) -> Result<()> { + self.rpc + .rpc(AuthorSetDefaultRequest { author_id }) + .await??; + Ok(()) + } + /// List document authors for which we have a secret key. pub async fn list(&self) -> Result>> { let stream = self.rpc.server_streaming(AuthorListRequest {}).await?; @@ -113,6 +125,9 @@ mod tests { let authors: Vec<_> = node.authors.list().await?.try_collect().await?; assert_eq!(authors.len(), 2); + node.authors.set_default(author_id).await?; + assert_eq!(node.authors.default().await?, author_id); + Ok(()) } } diff --git a/iroh/src/docs_engine.rs b/iroh/src/docs_engine.rs index e225e82062..3ec587787f 100644 --- a/iroh/src/docs_engine.rs +++ b/iroh/src/docs_engine.rs @@ -2,14 +2,15 @@ //! //! [`iroh_docs::Replica`] is also called documents here. -use std::{io, sync::Arc}; +use std::path::PathBuf; +use std::{io, str::FromStr, sync::Arc}; -use anyhow::Result; +use anyhow::{bail, Result}; use futures_lite::{Stream, StreamExt}; use iroh_blobs::downloader::Downloader; use iroh_blobs::{store::EntryStatus, Hash}; -use iroh_docs::AuthorId; use iroh_docs::{actor::SyncHandle, ContentStatus, ContentStatusCallback, Entry, NamespaceId}; +use iroh_docs::{Author, AuthorId}; use iroh_gossip::net::Gossip; use iroh_net::util::SharedAbortingJoinHandle; use iroh_net::{key::PublicKey, Endpoint, NodeAddr}; @@ -48,6 +49,61 @@ pub struct Engine { #[debug("ContentStatusCallback")] content_status_cb: ContentStatusCallback, default_author: AuthorId, + default_author_storage: Arc, +} + +/// Where to persist the default author. +/// +/// If set to `Mem`, a new author will be created in the docs store before spawning the sync +/// engine. Changing the default author will not be persisted. +/// +/// If set to `Persistent`, the default author will be loaded from and persisted to the specified +/// path (as base32 encoded string of the author's public key). +#[derive(Debug)] +pub enum DefaultAuthorStorage { + Mem, + Persistent(PathBuf), +} + +impl DefaultAuthorStorage { + pub async fn load(&self, docs_store: &SyncHandle) -> anyhow::Result { + match self { + Self::Mem => { + let author = Author::new(&mut rand::thread_rng()); + let author_id = author.id(); + docs_store.import_author(author).await?; + Ok(author_id) + } + Self::Persistent(ref path) => { + if path.exists() { + let data = tokio::fs::read_to_string(path).await?; + let author_id = AuthorId::from_str(&data)?; + if docs_store.export_author(author_id).await?.is_none() { + bail!("The default author is missing from the docs store. To recover, delete the file `{}`. Then iroh will create a new default author.", path.to_string_lossy()) + } + Ok(author_id) + } else { + let author = Author::new(&mut rand::thread_rng()); + let author_id = author.id(); + docs_store.import_author(author).await?; + tokio::fs::write(path, author_id.to_string()).await?; + Ok(author_id) + } + } + } + } + pub async fn save(&self, docs_store: &SyncHandle, author_id: AuthorId) -> anyhow::Result<()> { + if docs_store.export_author(author_id).await?.is_none() { + bail!("The author does not exist"); + } + match self { + Self::Mem => {} + Self::Persistent(ref path) => { + tokio::fs::write(path, author_id.to_string()).await?; + } + } + Ok(()) + } } impl Engine { @@ -55,14 +111,14 @@ impl Engine { /// /// This will spawn two tokio tasks for the live sync coordination and gossip actors, and a /// thread for the [`iroh_docs::actor::SyncHandle`]. - pub(crate) fn spawn( + pub(crate) async fn spawn( endpoint: Endpoint, gossip: Gossip, replica_store: iroh_docs::store::Store, bao_store: B, downloader: Downloader, - default_author: AuthorId, - ) -> Self { + default_author_storage: DefaultAuthorStorage, + ) -> anyhow::Result { let (live_actor_tx, to_live_actor_recv) = mpsc::channel(ACTOR_CHANNEL_CAP); let (to_gossip_actor, to_gossip_actor_recv) = mpsc::channel(ACTOR_CHANNEL_CAP); let me = endpoint.node_id().fmt_short(); @@ -98,14 +154,17 @@ impl Engine { .instrument(error_span!("sync", %me)), ); - Self { + let default_author = default_author_storage.load(&sync).await?; + + Ok(Self { endpoint, sync, to_live_actor: live_actor_tx, actor_handle: actor_handle.into(), content_status_cb, default_author, - } + default_author_storage: Arc::new(default_author_storage), + }) } /// Start to sync a document. diff --git a/iroh/src/docs_engine/rpc.rs b/iroh/src/docs_engine/rpc.rs index ae03f27a0a..bf94ed2dbc 100644 --- a/iroh/src/docs_engine/rpc.rs +++ b/iroh/src/docs_engine/rpc.rs @@ -8,9 +8,10 @@ use tokio_stream::StreamExt; use crate::client::docs::ShareMode; use crate::rpc_protocol::{ - AuthorGetDefaultRequest, AuthorGetDefaultResponse, AuthorDeleteRequest, AuthorDeleteResponse, - AuthorExportRequest, AuthorExportResponse, AuthorImportRequest, AuthorImportResponse, - DocGetSyncPeersRequest, DocGetSyncPeersResponse, + AuthorDeleteRequest, AuthorDeleteResponse, AuthorExportRequest, AuthorExportResponse, + AuthorGetDefaultRequest, AuthorGetDefaultResponse, AuthorImportRequest, AuthorImportResponse, + AuthorSetDefaultRequest, AuthorSetDefaultResponse, DocGetSyncPeersRequest, + DocGetSyncPeersResponse, }; use crate::{ docs_engine::Engine, @@ -51,6 +52,16 @@ impl Engine { } } + pub async fn author_set_default( + &self, + req: AuthorSetDefaultRequest, + ) -> RpcResult { + self.default_author_storage + .save(&self.sync, req.author_id) + .await?; + Ok(AuthorSetDefaultResponse) + } + pub fn author_list( &self, _req: AuthorListRequest, diff --git a/iroh/src/node.rs b/iroh/src/node.rs index 6724ce3f6c..8212d0114a 100644 --- a/iroh/src/node.rs +++ b/iroh/src/node.rs @@ -533,6 +533,21 @@ mod tests { assert!(iroh.is_ok()); } + // check that the default author can be set manually and is persisted. + let default_author = { + let iroh = Node::persistent(iroh_root).await?.spawn().await?; + let author = iroh.authors.create().await?; + iroh.authors.set_default(author).await?; + iroh.shutdown().await?; + author + }; + { + let iroh = Node::persistent(iroh_root).await?.spawn().await?; + let author = iroh.authors.default().await?; + assert_eq!(author, default_author); + iroh.shutdown().await?; + } + Ok(()) } } diff --git a/iroh/src/node/builder.rs b/iroh/src/node/builder.rs index 4fec428f77..ae25fc64b1 100644 --- a/iroh/src/node/builder.rs +++ b/iroh/src/node/builder.rs @@ -33,13 +33,10 @@ use tracing::{debug, error, error_span, info, trace, warn, Instrument}; use crate::{ client::RPC_ALPN, - docs_engine::Engine, + docs_engine::{DefaultAuthorStorage, Engine}, node::{Event, NodeInner}, rpc_protocol::{Request, Response, RpcService}, - util::{ - fs::{load_default_author, load_secret_key}, - path::IrohPaths, - }, + util::{fs::load_secret_key, path::IrohPaths}, }; use super::{rpc, rpc_status::RpcStatus, Callbacks, EventCallback, Node}; @@ -355,7 +352,7 @@ where /// This will create the underlying network server and spawn a tokio task accepting /// connections. The returned [`Node`] can be used to control the task as well as /// get information about it. - pub async fn spawn(mut self) -> Result> { + pub async fn spawn(self) -> Result> { trace!("spawning node"); let lp = LocalPoolHandle::new(num_cpus::get()); @@ -423,12 +420,12 @@ where let downloader = Downloader::new(self.blobs_store.clone(), endpoint.clone(), lp.clone()); // load or create the default author for documents - let default_author = match self.storage { + let default_author_storage = match self.storage { StorageConfig::Persistent(ref root) => { let path = IrohPaths::DefaultAuthor.with_root(root); - load_default_author(path, &mut self.docs_store).await? + DefaultAuthorStorage::Persistent(path) } - StorageConfig::Mem => self.docs_store.new_author(&mut rand::thread_rng())?.id(), + StorageConfig::Mem => DefaultAuthorStorage::Mem, }; // spawn the docs engine @@ -438,8 +435,9 @@ where self.docs_store, self.blobs_store.clone(), downloader.clone(), - default_author, - ); + default_author_storage, + ) + .await?; let sync_db = sync.sync.clone(); let callbacks = Callbacks::default(); diff --git a/iroh/src/node/rpc.rs b/iroh/src/node/rpc.rs index 0cefd6c9f1..f1909a6b9f 100644 --- a/iroh/src/node/rpc.rs +++ b/iroh/src/node/rpc.rs @@ -167,6 +167,12 @@ impl Handler { }) .await } + AuthorSetDefault(msg) => { + chan.rpc(msg, handler, |handler, req| async move { + handler.inner.sync.author_set_default(req).await + }) + .await + } DocOpen(msg) => { chan.rpc(msg, handler, |handler, req| async move { handler.inner.sync.doc_open(req).await diff --git a/iroh/src/rpc_protocol.rs b/iroh/src/rpc_protocol.rs index 4bc94b5c58..00183f74b2 100644 --- a/iroh/src/rpc_protocol.rs +++ b/iroh/src/rpc_protocol.rs @@ -452,6 +452,20 @@ pub struct AuthorGetDefaultResponse { pub author_id: AuthorId, } +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorSetDefaultRequest { + /// The id of the author + pub author_id: AuthorId, +} + +impl RpcMsg for AuthorSetDefaultRequest { + type Response = RpcResult; +} + +/// Response for [`AuthorGetDefaultRequest`] +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorSetDefaultResponse; + /// Delete an author #[derive(Serialize, Deserialize, Debug)] pub struct AuthorDeleteRequest { @@ -1082,6 +1096,7 @@ pub enum Request { AuthorList(AuthorListRequest), AuthorCreate(AuthorCreateRequest), AuthorGetDefault(AuthorGetDefaultRequest), + AuthorSetDefault(AuthorSetDefaultRequest), AuthorImport(AuthorImportRequest), AuthorExport(AuthorExportRequest), AuthorDelete(AuthorDeleteRequest), @@ -1143,6 +1158,7 @@ pub enum Response { AuthorList(RpcResult), AuthorCreate(RpcResult), AuthorGetDefault(AuthorGetDefaultResponse), + AuthorSetDefault(RpcResult), AuthorImport(RpcResult), AuthorExport(RpcResult), AuthorDelete(RpcResult), diff --git a/iroh/src/util/fs.rs b/iroh/src/util/fs.rs index d95cdf0629..d1af9650b0 100644 --- a/iroh/src/util/fs.rs +++ b/iroh/src/util/fs.rs @@ -3,12 +3,10 @@ use std::{ borrow::Cow, fs::read_dir, path::{Component, Path, PathBuf}, - str::FromStr, }; use anyhow::{bail, Context}; use bytes::Bytes; -use iroh_docs::AuthorId; use iroh_net::key::SecretKey; use tokio::io::AsyncWriteExt; use walkdir::WalkDir; @@ -121,35 +119,6 @@ pub fn relative_canonicalized_path_to_string(path: impl AsRef) -> anyhow:: canonicalized_path_to_string(path, true) } -/// Load the default author public key from a path, and check that it is present in the `docs_store`. -/// -/// If `path` does not exist, a new author keypair is created and persisted in the docs store, and -/// the public key is written to `path`, in base32 encoding. -/// -/// If `path` does exist, but does not contain an ed25519 public key in base32 encoding, an error -/// is returned. -/// -/// If `path` exists and is a valid author public key, but its secret key does not exist in the -/// docs store, an error is returned. -pub async fn load_default_author( - path: PathBuf, - docs_store: &mut iroh_docs::store::fs::Store, -) -> anyhow::Result { - if path.exists() { - let data = tokio::fs::read_to_string(&path).await?; - let author_id = AuthorId::from_str(&data)?; - if docs_store.get_author(&author_id)?.is_none() { - bail!("The default author is missing from the docs store. To recover, delete the file `{}`. Then iroh will create a new default author.", path.to_string_lossy()) - } - Ok(author_id) - } else { - let author_id = docs_store.new_author(&mut rand::thread_rng())?.id(); - docs_store.flush()?; - tokio::fs::write(path, author_id.to_string()).await?; - Ok(author_id) - } -} - /// Loads a [`SecretKey`] from the provided file, or stores a newly generated one /// at the given location. pub async fn load_secret_key(key_path: PathBuf) -> anyhow::Result {