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