1pub 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
36const IDLE_SYNC: Duration = Duration::from_secs(30);
39
40const META_SECRET: &str = "sync_secret_key";
41const META_ROOM: &str = "sync_room_key";
42
43#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub enum Transport {
85 #[default]
89 Direct,
90 RelayOnly,
100}
101
102pub struct SyncNode {
103 endpoint: Endpoint,
104 store: Arc<Mutex<Store>>,
105 room: Mutex<String>,
107 peers: Mutex<HashSet<EndpointId>>,
108}
109
110impl SyncNode {
111 pub async fn start(store: Arc<Mutex<Store>>) -> Result<Arc<Self>> {
114 Self::start_with(store, Transport::Direct).await
115 }
116
117 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 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 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 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 Err(e) => tracing::debug!(%peer, "sync failed: {e:#}"),
253 }
254 }
255 }
256
257 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 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 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 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}