Skip to main content

yaiba_sync/
lib.rs

1//! Peer-to-peer replication over iroh.
2//!
3//! Every `yaiba` instance is a full replica. Syncing is symmetric — no
4//! peer is authoritative and none has to be reachable at a fixed
5//! address. iroh dials by public key and hole-punches, so peers need
6//! outbound UDP and nothing else: no port forwarding, no inbound rule.
7//! When hole punching fails the connection falls back to a relay, which
8//! only forwards already-encrypted QUIC and cannot read the contents.
9//!
10//! Membership is a shared 32-byte *room key*, handed around inside a
11//! ticket. A peer that can't present it is dropped before any data
12//! moves.
13
14pub mod proto;
15
16use std::collections::HashSet;
17use std::str::FromStr;
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use anyhow::{Context, Result, anyhow, bail};
22use iroh::endpoint::Connection;
23use iroh::{Endpoint, EndpointId, SecretKey};
24use tokio::sync::Notify;
25use yaiba_core::Store;
26
27use crate::proto::{Hello, Offer, Push};
28
29const ALPN: &[u8] = b"yaiba/sync/1";
30
31/// How often to reach out to known peers even when nothing changed
32/// locally — catches writes made while this replica was offline.
33const IDLE_SYNC: Duration = Duration::from_secs(30);
34
35const META_SECRET: &str = "sync_secret_key";
36const META_ROOM: &str = "sync_room_key";
37
38/// What you hand someone to bring them into the same dataset.
39///
40/// Just an endpoint id and the room key: the address is resolved by
41/// iroh's discovery, so a ticket stays valid across network changes.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Ticket {
44    pub endpoint: EndpointId,
45    pub room: String,
46}
47
48impl std::fmt::Display for Ticket {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{}.{}", self.endpoint, self.room)
51    }
52}
53
54impl FromStr for Ticket {
55    type Err = anyhow::Error;
56
57    fn from_str(s: &str) -> Result<Self> {
58        let (endpoint, room) = s
59            .trim()
60            .split_once('.')
61            .context("a ticket looks like <endpoint-id>.<room-key>")?;
62        if from_hex(room).is_none() {
63            bail!("the room part of the ticket is not valid hex");
64        }
65        Ok(Ticket {
66            endpoint: endpoint
67                .parse()
68                .map_err(|e| anyhow!("bad endpoint id in ticket: {e}"))?,
69            room: room.to_ascii_lowercase(),
70        })
71    }
72}
73
74pub struct SyncNode {
75    endpoint: Endpoint,
76    store: Arc<Mutex<Store>>,
77    /// Hex room key. Replaced when joining someone else's group.
78    room: Mutex<String>,
79    peers: Mutex<HashSet<EndpointId>>,
80}
81
82impl SyncNode {
83    /// Bind an endpoint and start serving. Identity is persisted, so a
84    /// restart rejoins as the same peer with the same ticket.
85    pub async fn start(store: Arc<Mutex<Store>>) -> Result<Arc<Self>> {
86        let (secret, room) = {
87            let db = store.lock().unwrap_or_else(|e| e.into_inner());
88            let secret = match db.meta(META_SECRET)? {
89                Some(hex) => SecretKey::from_bytes(
90                    &from_hex(&hex)
91                        .and_then(|b| <[u8; 32]>::try_from(b).ok())
92                        .context("stored sync secret key is corrupt")?,
93                ),
94                None => {
95                    let key = generate_secret();
96                    db.set_meta(META_SECRET, &to_hex(&key.to_bytes()))?;
97                    key
98                }
99            };
100            let room = match db.meta(META_ROOM)? {
101                Some(room) => room,
102                None => {
103                    let room = to_hex(&generate_secret().to_bytes());
104                    db.set_meta(META_ROOM, &room)?;
105                    room
106                }
107            };
108            (secret, room)
109        };
110
111        let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)
112            .secret_key(secret)
113            .alpns(vec![ALPN.to_vec()])
114            .bind()
115            .await
116            .context("failed to bind the iroh endpoint")?;
117
118        let node = Arc::new(Self {
119            endpoint,
120            store,
121            room: Mutex::new(room),
122            peers: Mutex::new(HashSet::new()),
123        });
124
125        node.load_peers()?;
126
127        let listener = Arc::clone(&node);
128        tokio::spawn(async move { listener.accept_loop().await });
129
130        Ok(node)
131    }
132
133    pub fn ticket(&self) -> Ticket {
134        Ticket {
135            endpoint: self.endpoint.id(),
136            room: self.room.lock().unwrap_or_else(|e| e.into_inner()).clone(),
137        }
138    }
139
140    pub fn peer_ids(&self) -> Vec<EndpointId> {
141        self.peers
142            .lock()
143            .unwrap_or_else(|e| e.into_inner())
144            .iter()
145            .copied()
146            .collect()
147    }
148
149    /// Adopt a ticket: remember the peer and, since the room key names
150    /// the group, switch to theirs.
151    ///
152    /// Joining is deliberately one-way — the joiner moves to the host's
153    /// room rather than negotiating — so "I sent you a ticket" has an
154    /// unambiguous result.
155    pub fn join(&self, ticket: &Ticket) -> Result<()> {
156        if ticket.endpoint == self.endpoint.id() {
157            bail!("that ticket is this replica's own");
158        }
159        {
160            let db = self.store.lock().unwrap_or_else(|e| e.into_inner());
161            db.set_meta(META_ROOM, &ticket.room)?;
162            db.upsert_peer(&ticket.endpoint.to_string(), &ticket.to_string(), "")?;
163        }
164        *self.room.lock().unwrap_or_else(|e| e.into_inner()) = ticket.room.clone();
165        self.peers
166            .lock()
167            .unwrap_or_else(|e| e.into_inner())
168            .insert(ticket.endpoint);
169        Ok(())
170    }
171
172    fn load_peers(&self) -> Result<()> {
173        let stored = {
174            let db = self.store.lock().unwrap_or_else(|e| e.into_inner());
175            db.list_peers()?
176        };
177        let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
178        for (node, _ticket, _label) in stored {
179            if let Ok(id) = node.parse::<EndpointId>() {
180                peers.insert(id);
181            }
182        }
183        Ok(())
184    }
185
186    /// Background driver: sync on every local change, and on a timer so
187    /// peers that were offline still catch up.
188    pub async fn run(self: Arc<Self>, notify: Arc<Notify>) {
189        loop {
190            tokio::select! {
191                _ = notify.notified() => {}
192                _ = tokio::time::sleep(IDLE_SYNC) => {}
193            }
194            self.sync_all().await;
195        }
196    }
197
198    pub async fn sync_all(&self) {
199        for peer in self.peer_ids() {
200            match self.sync_with(peer).await {
201                Ok(applied) if applied > 0 => {
202                    tracing::info!(%peer, applied, "merged updates from peer");
203                }
204                Ok(_) => tracing::debug!(%peer, "peer already up to date"),
205                // A peer being offline is the normal case, not an error
206                // worth shouting about.
207                Err(e) => tracing::debug!(%peer, "sync failed: {e:#}"),
208            }
209        }
210    }
211
212    /// Dial a peer and run one full exchange. Returns how many entries
213    /// this replica took from them.
214    pub async fn sync_with(&self, peer: EndpointId) -> Result<usize> {
215        let conn = self
216            .endpoint
217            .connect(peer, ALPN)
218            .await
219            .with_context(|| format!("could not reach {peer}"))?;
220        let (mut send, mut recv) = conn.open_bi().await?;
221
222        let hello = Hello {
223            room: self.room.lock().unwrap_or_else(|e| e.into_inner()).clone(),
224            vv: self.with_store(|db| db.version_vector())?,
225        };
226        proto::write_frame(&mut send, &hello).await?;
227
228        let offer: Offer = proto::read_frame(&mut recv).await?;
229        let applied = self.with_store(|db| db.merge(&offer.entries, &offer.vv))?;
230
231        // Now that their vector is known, send back only what they lack.
232        let entries = self.with_store(|db| db.entries_since(&offer.vv))?;
233        proto::write_frame(&mut send, &Push { entries }).await?;
234        send.finish()?;
235
236        self.with_store(|db| db.touch_peer(&peer.to_string()))?;
237        conn.close(0u32.into(), b"done");
238        Ok(applied)
239    }
240
241    async fn accept_loop(self: Arc<Self>) {
242        while let Some(incoming) = self.endpoint.accept().await {
243            let node = Arc::clone(&self);
244            tokio::spawn(async move {
245                match incoming.await {
246                    Ok(conn) => {
247                        if let Err(e) = node.serve(conn).await {
248                            tracing::debug!("inbound sync failed: {e:#}");
249                        }
250                    }
251                    Err(e) => tracing::debug!("inbound connection failed: {e:#}"),
252                }
253            });
254        }
255    }
256
257    async fn serve(&self, conn: Connection) -> Result<()> {
258        let (mut send, mut recv) = conn.accept_bi().await?;
259        let hello: Hello = proto::read_frame(&mut recv).await?;
260
261        let room = self.room.lock().unwrap_or_else(|e| e.into_inner()).clone();
262        if !proto::room_matches(&hello.room, &room) {
263            conn.close(1u32.into(), b"room mismatch");
264            bail!("rejected a peer presenting the wrong room key");
265        }
266
267        let offer = Offer {
268            vv: self.with_store(|db| db.version_vector())?,
269            entries: self.with_store(|db| db.entries_since(&hello.vv))?,
270        };
271        proto::write_frame(&mut send, &offer).await?;
272
273        let push: Push = proto::read_frame(&mut recv).await?;
274        let applied = self.with_store(|db| db.merge(&push.entries, &hello.vv))?;
275        if applied > 0 {
276            tracing::info!(applied, "merged updates from an inbound peer");
277        }
278
279        // An inbound peer that knows the room is a peer worth keeping,
280        // so the next sync can be initiated from this side too.
281        let id = conn.remote_id();
282        let is_new = self
283            .peers
284            .lock()
285            .unwrap_or_else(|e| e.into_inner())
286            .insert(id);
287        if is_new {
288            let ticket = Ticket { endpoint: id, room };
289            self.with_store(|db| db.upsert_peer(&id.to_string(), &ticket.to_string(), ""))?;
290        }
291
292        send.finish()?;
293        Ok(())
294    }
295
296    /// Run a store operation without holding the lock across an await.
297    fn with_store<T>(&self, f: impl FnOnce(&mut Store) -> yaiba_core::Result<T>) -> Result<T> {
298        let mut db = self.store.lock().unwrap_or_else(|e| e.into_inner());
299        Ok(f(&mut db)?)
300    }
301}
302
303fn generate_secret() -> SecretKey {
304    SecretKey::generate()
305}
306
307fn to_hex(bytes: &[u8]) -> String {
308    bytes.iter().map(|b| format!("{b:02x}")).collect()
309}
310
311fn from_hex(s: &str) -> Option<Vec<u8>> {
312    if !s.len().is_multiple_of(2) || s.is_empty() {
313        return None;
314    }
315    (0..s.len())
316        .step_by(2)
317        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
318        .collect()
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn tickets_round_trip() {
327        let ticket = Ticket {
328            endpoint: generate_secret().public(),
329            room: to_hex(&[0xab; 32]),
330        };
331        assert_eq!(ticket.to_string().parse::<Ticket>().unwrap(), ticket);
332    }
333
334    #[test]
335    fn malformed_tickets_are_rejected() {
336        assert!("nonsense".parse::<Ticket>().is_err());
337        assert!("nonsense.zzzz".parse::<Ticket>().is_err());
338    }
339
340    #[test]
341    fn hex_round_trips() {
342        let bytes = [0u8, 1, 15, 16, 255];
343        assert_eq!(from_hex(&to_hex(&bytes)).unwrap(), bytes);
344        assert!(from_hex("abc").is_none(), "odd length");
345        assert!(from_hex("").is_none());
346    }
347}