1use std::{
4 collections::{BTreeMap, HashMap, HashSet},
5 net::SocketAddr,
6 sync::{
7 atomic::{AtomicBool, Ordering},
8 Arc, Mutex,
9 },
10};
11
12use futures::stream;
13use iroh::{
14 address_lookup::{AddressLookup, EndpointData, EndpointInfo, Error, Item},
15 EndpointId, RelayUrl, TransportAddr,
16};
17
18#[derive(Debug, Clone)]
29pub struct SourceScopedAddressLookup {
30 shared: Arc<Shared>,
31}
32
33#[derive(Debug)]
34struct Shared {
35 provenance: &'static str,
36 state: Mutex<State>,
37}
38
39#[derive(Debug, Default)]
40struct State {
41 next_source_id: u64,
42 contributions: BTreeMap<(u64, String), Contribution>,
43 effective: HashMap<EndpointId, EndpointData>,
44}
45
46#[derive(Debug)]
47struct Contribution {
48 endpoint_id: EndpointId,
49 node_id: String,
50 data: EndpointData,
51}
52
53#[derive(Debug, Clone)]
55pub struct AddressLookupSource {
56 lease: Arc<SourceLease>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AddressLookupRemoval {
62 pub node_id: String,
64 pub has_remaining_contributions: bool,
68 pub remaining_addrs: Vec<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct AddressLookupUpsert {
76 pub node_id: String,
78 pub effective_addrs: Vec<String>,
81 pub replaced: Option<AddressLookupRemoval>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct AddressLookupUpsertError {
96 message: String,
97 removal: Option<AddressLookupRemoval>,
98}
99
100impl AddressLookupUpsertError {
101 pub fn removal(&self) -> Option<&AddressLookupRemoval> {
103 self.removal.as_ref()
104 }
105
106 pub fn into_removal(self) -> Option<AddressLookupRemoval> {
109 self.removal
110 }
111}
112
113impl std::fmt::Display for AddressLookupUpsertError {
114 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 formatter.write_str(&self.message)
116 }
117}
118
119impl std::error::Error for AddressLookupUpsertError {}
120
121#[derive(Debug)]
122struct SourceLease {
123 lookup: SourceScopedAddressLookup,
124 source_id: u64,
125 retired: AtomicBool,
126}
127
128impl SourceScopedAddressLookup {
129 pub fn new(provenance: &'static str) -> Self {
131 Self {
132 shared: Arc::new(Shared {
133 provenance,
134 state: Mutex::new(State {
135 next_source_id: 1,
136 ..State::default()
137 }),
138 }),
139 }
140 }
141
142 pub fn new_source(&self) -> AddressLookupSource {
144 let source_id = {
145 let mut state = self
146 .shared
147 .state
148 .lock()
149 .unwrap_or_else(|error| error.into_inner());
150 let source_id = state.next_source_id;
151 state.next_source_id = state
152 .next_source_id
153 .checked_add(1)
154 .expect("address-lookup source id space exhausted");
155 source_id
156 };
157 AddressLookupSource {
158 lease: Arc::new(SourceLease {
159 lookup: self.clone(),
160 source_id,
161 retired: AtomicBool::new(false),
162 }),
163 }
164 }
165}
166
167impl SourceLease {
168 fn retire(&self) {
169 if self.retired.swap(true, Ordering::AcqRel) {
170 return;
171 }
172 let mut state = self
173 .lookup
174 .shared
175 .state
176 .lock()
177 .unwrap_or_else(|error| error.into_inner());
178 let mut affected = HashSet::new();
179 state.contributions.retain(|(source_id, _), contribution| {
180 if *source_id == self.source_id {
181 affected.insert(contribution.endpoint_id);
182 false
183 } else {
184 true
185 }
186 });
187 for endpoint_id in affected {
188 recompute_endpoint(&mut state, endpoint_id);
189 }
190 }
191}
192
193impl Drop for SourceLease {
194 fn drop(&mut self) {
195 self.retire();
196 }
197}
198
199impl AddressLookupSource {
200 pub fn retire(&self) {
206 self.lease.retire();
207 }
208
209 pub fn upsert(
215 &self,
216 instance_name: &str,
217 node_id: &str,
218 addrs: &[String],
219 ) -> Result<AddressLookupUpsert, AddressLookupUpsertError> {
220 let parsed_endpoint_id = node_id
221 .parse::<EndpointId>()
222 .map_err(|error| format!("invalid node id {node_id:?}: {error}"));
223 let key = (self.lease.source_id, instance_name.to_string());
224
225 let mut state = self
226 .lease
227 .lookup
228 .shared
229 .state
230 .lock()
231 .unwrap_or_else(|error| error.into_inner());
232 if self.lease.retired.load(Ordering::Acquire) {
233 return Err(AddressLookupUpsertError {
234 message: "address-lookup source has been retired".to_string(),
235 removal: None,
236 });
237 }
238 let endpoint_id = match parsed_endpoint_id {
239 Ok(endpoint_id) => endpoint_id,
240 Err(message) => {
241 let removal = remove_contribution(&mut state, &key);
242 return Err(AddressLookupUpsertError { message, removal });
243 }
244 };
245 let contribution = Contribution {
246 endpoint_id,
247 node_id: crate::base32_encode(endpoint_id.as_bytes()),
248 data: endpoint_data(addrs),
249 };
250 let old = state.contributions.insert(key, contribution);
251 if let Some(old_endpoint_id) = old
252 .as_ref()
253 .map(|old| old.endpoint_id)
254 .filter(|old_endpoint_id| *old_endpoint_id != endpoint_id)
255 {
256 recompute_endpoint(&mut state, old_endpoint_id);
257 }
258 recompute_endpoint(&mut state, endpoint_id);
259 let effective_addrs = source_effective_addrs(&state, self.lease.source_id, endpoint_id);
260 let replaced = old.and_then(|old| {
261 (old.endpoint_id != endpoint_id).then(|| {
262 removal_snapshot(&state, self.lease.source_id, old.endpoint_id, old.node_id)
263 })
264 });
265 Ok(AddressLookupUpsert {
266 node_id: crate::base32_encode(endpoint_id.as_bytes()),
267 effective_addrs,
268 replaced,
269 })
270 }
271
272 pub fn remove(&self, instance_name: &str) -> Option<String> {
278 self.remove_with_snapshot(instance_name)
279 .map(|removal| removal.node_id)
280 }
281
282 pub fn remove_with_snapshot(&self, instance_name: &str) -> Option<AddressLookupRemoval> {
285 let mut state = self
286 .lease
287 .lookup
288 .shared
289 .state
290 .lock()
291 .unwrap_or_else(|error| error.into_inner());
292 if self.lease.retired.load(Ordering::Acquire) {
293 return None;
294 }
295 let key = (self.lease.source_id, instance_name.to_string());
296 remove_contribution(&mut state, &key)
297 }
298}
299
300fn remove_contribution(state: &mut State, key: &(u64, String)) -> Option<AddressLookupRemoval> {
301 let contribution = state.contributions.remove(key)?;
302 recompute_endpoint(state, contribution.endpoint_id);
303 Some(removal_snapshot(
304 state,
305 key.0,
306 contribution.endpoint_id,
307 contribution.node_id,
308 ))
309}
310
311fn removal_snapshot(
312 state: &State,
313 source_id: u64,
314 endpoint_id: EndpointId,
315 advertised_node_id: String,
316) -> AddressLookupRemoval {
317 let has_remaining_contributions =
318 state
319 .contributions
320 .iter()
321 .any(|((remaining_source_id, _), remaining)| {
322 *remaining_source_id == source_id && remaining.endpoint_id == endpoint_id
323 });
324 AddressLookupRemoval {
325 node_id: advertised_node_id,
326 has_remaining_contributions,
327 remaining_addrs: source_effective_addrs(state, source_id, endpoint_id),
328 }
329}
330
331fn source_effective_addrs(state: &State, source_id: u64, endpoint_id: EndpointId) -> Vec<String> {
332 let data = EndpointData::from_iter(
333 state
334 .contributions
335 .iter()
336 .filter(|((remaining_source_id, _), contribution)| {
337 *remaining_source_id == source_id && contribution.endpoint_id == endpoint_id
338 })
339 .flat_map(|(_, contribution)| contribution.data.addrs().cloned()),
340 );
341 data.addrs()
342 .filter_map(|addr| match addr {
347 TransportAddr::Ip(addr) => Some(addr.to_string()),
348 TransportAddr::Relay(url) => Some(url.to_string()),
349 _ => None,
350 })
351 .collect()
352}
353
354fn endpoint_data(addrs: &[String]) -> EndpointData {
355 let mut data = EndpointData::default();
356 for addr in addrs {
357 let addr = addr.trim();
358 if let Ok(addr) = addr.parse::<SocketAddr>() {
359 if addr.port() <= 1 {
360 continue;
361 }
362 data.add_addrs([TransportAddr::Ip(addr)]);
363 } else if let Ok(relay) = addr.parse::<RelayUrl>() {
364 data.add_relay_url(relay);
365 }
366 }
367 data
368}
369
370fn recompute_endpoint(state: &mut State, endpoint_id: EndpointId) {
371 let data = EndpointData::from_iter(
372 state
373 .contributions
374 .values()
375 .filter(|contribution| contribution.endpoint_id == endpoint_id)
376 .flat_map(|contribution| contribution.data.addrs().cloned()),
377 );
378 if data.has_addrs() {
379 state.effective.insert(endpoint_id, data);
380 } else {
381 state.effective.remove(&endpoint_id);
382 }
383}
384
385impl AddressLookup for SourceScopedAddressLookup {
386 fn resolve(
387 &self,
388 endpoint_id: EndpointId,
389 ) -> Option<futures::stream::BoxStream<'static, Result<Item, Error>>> {
390 let data = self
391 .shared
392 .state
393 .lock()
394 .unwrap_or_else(|error| error.into_inner())
395 .effective
396 .get(&endpoint_id)
397 .cloned()?;
398 let item = Item::new(
399 EndpointInfo::from_parts(endpoint_id, data),
400 self.shared.provenance,
401 None,
402 );
403 Some(Box::pin(stream::once(async move { Ok(item) })))
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use futures::StreamExt;
410 use iroh::{address_lookup::AddressLookup, EndpointId, SecretKey, TransportAddr};
411
412 use super::SourceScopedAddressLookup;
413
414 fn endpoint_id(seed: u8) -> EndpointId {
415 SecretKey::from_bytes(&[seed; 32]).public()
416 }
417
418 fn direct_addrs(lookup: &SourceScopedAddressLookup, id: EndpointId) -> Vec<String> {
419 let Some(mut resolved) = lookup.resolve(id) else {
420 return Vec::new();
421 };
422 let item = futures::executor::block_on(async { resolved.next().await })
423 .expect("one lookup item")
424 .expect("successful lookup");
425 let mut addrs: Vec<String> = item
426 .endpoint_info()
427 .addrs()
428 .filter_map(|addr| match addr {
429 TransportAddr::Ip(addr) => Some(addr.to_string()),
430 _ => None,
431 })
432 .collect();
433 addrs.sort();
434 addrs
435 }
436
437 #[test]
438 fn replacing_an_instance_retracts_its_old_node_atomically() {
439 let lookup = SourceScopedAddressLookup::new("test-discovery");
440 let source = lookup.new_source();
441 let old_id = endpoint_id(1);
442 let new_id = endpoint_id(2);
443
444 source
445 .upsert(
446 "stable-instance",
447 &old_id.to_string(),
448 &["192.168.1.2:4433".to_string()],
449 )
450 .expect("valid first record");
451 let update = source
452 .upsert(
453 "stable-instance",
454 &new_id.to_string(),
455 &["192.168.1.3:4434".to_string()],
456 )
457 .expect("valid replacement record");
458
459 assert_eq!(update.node_id, crate::base32_encode(new_id.as_bytes()));
460 assert_eq!(update.effective_addrs, ["192.168.1.3:4434"]);
461 let replaced = update.replaced.expect("old-node transition");
462 assert_eq!(replaced.node_id, crate::base32_encode(old_id.as_bytes()));
463 assert!(!replaced.has_remaining_contributions);
464 assert!(replaced.remaining_addrs.is_empty());
465
466 assert!(
467 lookup.resolve(old_id).is_none(),
468 "the replaced node must be retracted"
469 );
470 assert_eq!(direct_addrs(&lookup, new_id), ["192.168.1.3:4434"]);
471 }
472
473 #[test]
474 fn changing_instance_identity_keeps_other_sources_out_of_its_event_snapshot() {
475 let lookup = SourceScopedAddressLookup::new("test-discovery");
476 let replacing_source = lookup.new_source();
477 let overlapping_source = lookup.new_source();
478 let old_id = endpoint_id(10);
479 let new_id = endpoint_id(11);
480
481 replacing_source
482 .upsert(
483 "stable-instance",
484 &old_id.to_string(),
485 &["192.168.10.1:4433".to_string()],
486 )
487 .unwrap();
488 overlapping_source
489 .upsert(
490 "other-instance",
491 &old_id.to_string(),
492 &["192.168.10.2:4433".to_string()],
493 )
494 .unwrap();
495
496 let update = replacing_source
497 .upsert(
498 "stable-instance",
499 &new_id.to_string(),
500 &["192.168.11.1:4433".to_string()],
501 )
502 .unwrap();
503 let replaced = update.replaced.expect("old-node transition");
504
505 assert!(!replaced.has_remaining_contributions);
506 assert!(replaced.remaining_addrs.is_empty());
507 assert_eq!(direct_addrs(&lookup, old_id), ["192.168.10.2:4433"]);
508 assert_eq!(update.effective_addrs, ["192.168.11.1:4433"]);
509 assert_eq!(direct_addrs(&lookup, new_id), ["192.168.11.1:4433"]);
510 }
511
512 #[test]
513 fn replacing_an_instance_on_the_same_node_drops_stale_addresses() {
514 let lookup = SourceScopedAddressLookup::new("test-discovery");
515 let source = lookup.new_source();
516 let id = endpoint_id(7);
517
518 source
519 .upsert(
520 "stable-instance",
521 &id.to_string(),
522 &["192.168.1.7:4433".to_string()],
523 )
524 .unwrap();
525 source
526 .upsert(
527 "stable-instance",
528 &id.to_string(),
529 &["192.168.1.8:4434".to_string()],
530 )
531 .unwrap();
532
533 assert_eq!(direct_addrs(&lookup, id), ["192.168.1.8:4434"]);
534 }
535
536 #[test]
537 fn invalid_instance_replacement_retracts_the_previous_valid_record() {
538 let lookup = SourceScopedAddressLookup::new("test-discovery");
539 let source = lookup.new_source();
540 let id = endpoint_id(8);
541
542 source
543 .upsert(
544 "stable-instance",
545 &id.to_string(),
546 &["192.168.1.8:4433".to_string()],
547 )
548 .unwrap();
549 let error = source
550 .upsert(
551 "stable-instance",
552 "not-a-valid-node-id",
553 &["192.168.1.9:4434".to_string()],
554 )
555 .expect_err("malformed authoritative update must be rejected");
556
557 assert!(error.to_string().contains("invalid node id"));
558 let removal = error
559 .into_removal()
560 .expect("the rejected update removed its old contribution");
561 assert_eq!(removal.node_id, crate::base32_encode(id.as_bytes()));
562 assert!(!removal.has_remaining_contributions);
563 assert!(removal.remaining_addrs.is_empty());
564 assert!(
565 lookup.resolve(id).is_none(),
566 "a malformed replacement must not preserve the stale dial target"
567 );
568 }
569
570 #[test]
571 fn removing_one_source_keeps_the_other_source_in_the_global_lookup_only() {
572 let lookup = SourceScopedAddressLookup::new("test-discovery");
573 let source_a = lookup.new_source();
574 let source_b = lookup.new_source();
575 let id = endpoint_id(3);
576 let node_id = id.to_string();
577
578 source_a
579 .upsert(
580 "peer-instance",
581 &node_id,
582 &["10.0.0.1:4433".to_string(), "10.0.0.2:4433".to_string()],
583 )
584 .unwrap();
585 source_b
586 .upsert(
587 "peer-instance",
588 &node_id,
589 &["10.0.0.2:4433".to_string(), "10.0.0.3:4433".to_string()],
590 )
591 .unwrap();
592
593 assert_eq!(
594 direct_addrs(&lookup, id),
595 ["10.0.0.1:4433", "10.0.0.2:4433", "10.0.0.3:4433"],
596 "live contributions must be unioned and deduplicated"
597 );
598 let removal = source_a
599 .remove_with_snapshot("peer-instance")
600 .expect("source A contribution");
601 assert_eq!(removal.node_id, crate::base32_encode(id.as_bytes()));
602 assert!(!removal.has_remaining_contributions);
603 assert!(
604 removal.remaining_addrs.is_empty(),
605 "one browse must not publish another browse's snapshot"
606 );
607 assert_eq!(
608 direct_addrs(&lookup, id),
609 ["10.0.0.2:4433", "10.0.0.3:4433"],
610 "removing source A must not erase source B"
611 );
612 }
613
614 #[test]
615 fn removal_preserves_presence_when_the_remaining_instance_is_not_dialable() {
616 let lookup = SourceScopedAddressLookup::new("test-discovery");
617 let source = lookup.new_source();
618 let id = endpoint_id(9);
619 let node_id = id.to_string();
620
621 source
622 .upsert(
623 "dialable-instance",
624 &node_id,
625 &["10.0.0.9:4433".to_string()],
626 )
627 .unwrap();
628 source
629 .upsert(
630 "present-without-path",
631 &node_id,
632 &["10.0.0.10:1".to_string()],
633 )
634 .unwrap();
635
636 let removal = source
637 .remove_with_snapshot("dialable-instance")
638 .expect("dialable contribution");
639 assert!(removal.has_remaining_contributions);
640 assert!(removal.remaining_addrs.is_empty());
641 assert!(lookup.resolve(id).is_none());
642 }
643
644 #[test]
645 fn undialable_or_empty_updates_never_resolve() {
646 let lookup = SourceScopedAddressLookup::new("test-discovery");
647 let source = lookup.new_source();
648 let id = endpoint_id(4);
649 let node_id = id.to_string();
650
651 source
652 .upsert(
653 "peer-instance",
654 &node_id,
655 &[
656 "192.168.1.9:0".to_string(),
657 "192.168.1.9:1".to_string(),
658 "not-an-address".to_string(),
659 ],
660 )
661 .unwrap();
662 assert!(
663 lookup.resolve(id).is_none(),
664 "port 0/1 and malformed inputs are not dialable"
665 );
666
667 source
668 .upsert("peer-instance", &node_id, &["192.168.1.9:4433".to_string()])
669 .unwrap();
670 assert_eq!(direct_addrs(&lookup, id), ["192.168.1.9:4433"]);
671
672 source.upsert("peer-instance", &node_id, &[]).unwrap();
673 assert!(
674 lookup.resolve(id).is_none(),
675 "an empty update must retract the previous dialable contribution"
676 );
677 }
678
679 #[test]
680 fn only_the_last_source_clone_retires_its_contributions() {
681 let lookup = SourceScopedAddressLookup::new("test-discovery");
682 let source = lookup.new_source();
683 let source_clone = source.clone();
684 let id = endpoint_id(5);
685
686 source
687 .upsert(
688 "peer-instance",
689 &id.to_string(),
690 &["172.16.0.4:4433".to_string()],
691 )
692 .unwrap();
693 drop(source);
694 assert_eq!(direct_addrs(&lookup, id), ["172.16.0.4:4433"]);
695
696 drop(source_clone);
697 assert!(
698 lookup.resolve(id).is_none(),
699 "dropping the final lease clone must retire the source"
700 );
701 }
702
703 #[test]
704 fn explicit_retirement_is_idempotent_and_prevents_resurrection() {
705 let lookup = SourceScopedAddressLookup::new("test-discovery");
706 let source = lookup.new_source();
707 let source_clone = source.clone();
708 let id = endpoint_id(6);
709
710 source
711 .upsert(
712 "peer-instance",
713 &id.to_string(),
714 &["172.16.0.5:4433".to_string()],
715 )
716 .unwrap();
717 source.retire();
718 source.retire();
719
720 assert!(lookup.resolve(id).is_none());
721 assert!(
722 source_clone
723 .upsert(
724 "late-instance",
725 &id.to_string(),
726 &["172.16.0.6:4433".to_string()],
727 )
728 .is_err(),
729 "a late callback must not resurrect a retired browse source"
730 );
731 assert_eq!(source_clone.remove("peer-instance"), None);
732 assert!(lookup.resolve(id).is_none());
733 }
734}