1mod 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
38const IDLE_SYNC: Duration = Duration::from_secs(30);
41
42const META_SECRET: &str = "sync_secret_key";
43const META_ROOM: &str = "sync_room_key";
44
45#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub enum Transport {
87 #[default]
91 Direct,
92 RelayOnly,
102}
103
104pub struct SyncNode {
105 endpoint: Endpoint,
106 gate: Gate,
107 room: Mutex<String>,
109 peers: Mutex<HashSet<EndpointId>>,
110 inbound: Mutex<Option<tokio::task::AbortHandle>>,
118}
119
120impl SyncNode {
121 pub async fn start(store: Arc<Mutex<Store>>) -> Result<Arc<Self>> {
124 Self::start_with(store, Transport::Direct).await
125 }
126
127 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 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 pub fn shutdown(&self) {
206 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 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 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 Err(e) => tracing::debug!(%peer, "sync failed: {e:#}"),
292 }
293 }
294 }
295
296 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 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 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 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}