Skip to main content

iroh_http_core/endpoint/
source_scoped_lookup.rs

1//! Source-scoped address discovery for adapters with browse-session lifetimes.
2
3use 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/// An in-process iroh address lookup whose records retain browse-source and
19/// DNS-SD service-instance identity.
20///
21/// Clone this value to register it with an endpoint. Create an
22/// [`AddressLookupSource`] for every independent browse session.
23///
24/// Retiring a source removes it from all future [`AddressLookup::resolve`]
25/// snapshots. Iroh 1.0.2 can separately retain paths it already consumed in
26/// its private remote-path cache; that cache has no public invalidation API and
27/// is cleared only with the endpoint itself.
28#[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/// The contribution namespace owned by one browse session.
54#[derive(Debug, Clone)]
55pub struct AddressLookupSource {
56    lease: Arc<SourceLease>,
57}
58
59/// Result of removing one service-instance contribution.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AddressLookupRemoval {
62    /// Canonical lowercase-base32 node id of the removed instance.
63    pub node_id: String,
64    /// Whether another live instance in this browse source still advertises
65    /// this node, independent of whether that contribution currently has a
66    /// dialable address.
67    pub has_remaining_contributions: bool,
68    /// Effective addresses still contributed for that node by other live
69    /// instances in this browse source.
70    pub remaining_addrs: Vec<String>,
71}
72
73/// Effective state changes produced by a successful contribution upsert.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct AddressLookupUpsert {
76    /// Canonical lowercase-base32 node id of the inserted contribution.
77    pub node_id: String,
78    /// This browse source's effective address union for the newly advertised
79    /// node after the update.
80    pub effective_addrs: Vec<String>,
81    /// Transition for the previously advertised node when this service
82    /// instance changed endpoint identity. This is `None` for a new instance
83    /// or an update that retains the same parsed endpoint id.
84    pub replaced: Option<AddressLookupRemoval>,
85}
86
87/// Failure to apply an address-lookup contribution.
88///
89/// A malformed authoritative replacement retracts the prior contribution for
90/// the same service instance. In that case [`Self::into_removal`] exposes the
91/// removed peer and its remaining effective address union so adapters can
92/// publish the corresponding state transition instead of leaving consumers
93/// with a stale active peer.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct AddressLookupUpsertError {
96    message: String,
97    removal: Option<AddressLookupRemoval>,
98}
99
100impl AddressLookupUpsertError {
101    /// Returns the state transition caused while rejecting this update, if any.
102    pub fn removal(&self) -> Option<&AddressLookupRemoval> {
103        self.removal.as_ref()
104    }
105
106    /// Consumes the error and returns the state transition caused while
107    /// rejecting this update, if any.
108    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    /// Creates an empty lookup. `provenance` is attached to every resolved item.
130    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    /// Creates an independent contribution namespace for one browse session.
143    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    /// Synchronously removes every contribution owned by this source.
201    ///
202    /// Retirement is shared by all clones, is idempotent, and permanently
203    /// rejects later updates. It is also performed automatically when the last
204    /// source clone is dropped.
205    pub fn retire(&self) {
206        self.lease.retire();
207    }
208
209    /// Inserts or replaces one DNS-SD service-instance contribution.
210    ///
211    /// The returned snapshot lets adapters publish the new node's full
212    /// effective union and, when the instance changed identity, the old node's
213    /// transition before the new active event.
214    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    /// Removes this source's contribution for `instance_name`.
273    ///
274    /// The returned value is the removed record's canonical lowercase-base32
275    /// node id. This lets an expiry event recover its peer identity even though
276    /// DNS-SD removal callbacks carry only the service instance name.
277    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    /// Removes one instance and returns the node's remaining effective
283    /// snapshot atomically with that removal.
284    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        // Adapter events carry the wire values themselves, while
343        // `TransportAddr::Display` deliberately prefixes them with their
344        // transport kind (`ip:` / `relay:`). Preserve the raw socket/URL
345        // representation accepted by `upsert`.
346        .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}