1pub 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
31const IDLE_SYNC: Duration = Duration::from_secs(30);
34
35const META_SECRET: &str = "sync_secret_key";
36const META_ROOM: &str = "sync_room_key";
37
38#[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 room: Mutex<String>,
79 peers: Mutex<HashSet<EndpointId>>,
80}
81
82impl SyncNode {
83 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 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 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 Err(e) => tracing::debug!(%peer, "sync failed: {e:#}"),
208 }
209 }
210 }
211
212 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 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 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 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}