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