Skip to main content

ts_runtime/peer_tracker/
peer_db.rs

1use std::{
2    collections::HashMap,
3    fmt::{Debug, Formatter},
4    hash::Hash,
5    net::IpAddr,
6};
7
8use ts_bart::{RouteModification, RoutingTable, RoutingTableExt};
9use ts_control::{Node, StableNodeId};
10use ts_keys::{DiscoPublicKey, NodePublicKey};
11use ts_transport::PeerId;
12
13mod private {
14    use super::*;
15
16    pub trait Sealed {}
17
18    impl Sealed for PeerId {}
19    impl Sealed for NodePublicKey {}
20    impl Sealed for DiscoPublicKey {}
21    impl Sealed for StableNodeId {}
22    impl Sealed for ts_control::NodeId {}
23    impl Sealed for PeerName {}
24    impl Sealed for &str {}
25    impl Sealed for IpAddr {}
26    impl Sealed for ipnet::IpNet {}
27}
28
29/// A [`Node`] field indexed by [`PeerDb`].
30pub trait IndexedField: Debug + private::Sealed {
31    /// Look up the peer id that has this field.
32    fn lookup(&self, db: &PeerDb) -> Option<PeerId>;
33}
34
35type Index<T> = HashMap<T, PeerId>;
36type PeerName = String;
37
38/// Canonicalize a DNS name as a key for [`IndexState::name_idx`].
39///
40/// DNS names are case-insensitive, and an fqdn may be presented with or without the root
41/// (trailing) dot. We store and look names up in a single canonical form — lowercased, with a
42/// single trailing dot stripped — so lookups match regardless of the caller's casing or trailing
43/// dot. This mirrors tsnet's `canonMapKey` (`net/tsdial/dnsmap.go`) for MagicDNS-name parity.
44fn canon_name(name: &str) -> String {
45    name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
46}
47
48/// A database that stores a map of peers by [`PeerId`] and multiple indices.
49///
50/// Assumes that _all indexed fields_ are unique per-node, with a few notable exceptions:
51///
52/// - Hostname may be duplicated, though the fqdn (including the tailnet component) may not
53///   be.
54/// - Accepted routes may overlap.
55#[derive(Default, Clone)]
56pub struct PeerDb {
57    peers: HashMap<PeerId, Node>,
58    index_state: IndexState,
59    next_id: u32,
60}
61
62impl Debug for PeerDb {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        self.peers.fmt(f)
65    }
66}
67
68#[derive(Default, Clone)]
69struct IndexState {
70    /// Index on the node's [`NodePublicKey`].
71    nk_idx: Index<NodePublicKey>,
72    /// Index on the [`DiscoPublicKey`], assuming it's known.
73    disco_idx: Index<DiscoPublicKey>,
74    /// Index on the peer [`StableNodeId`].
75    stableid_idx: Index<StableNodeId>,
76    /// Index for the [`ts_control::NodeId`].
77    ///
78    /// This is a numeric ID assigned by control which could overlap across different
79    /// control regions (by contrast to [`StableNodeId`], which should not). We need this
80    /// field because control indicates node patches and deletions by this id rather than
81    /// the stable id.
82    control_idx: Index<ts_control::NodeId>,
83    /// Index on the peer name and FQDN.
84    name_idx: Index<PeerName>,
85    /// Index on the node's tailnet IPv4 and IPv6.
86    ip_idx: ts_bart::Table<PeerId>,
87    /// Index on the node's accepted routes.
88    ///
89    /// These may overlap between nodes, hence this stores a vec of matching node ids for
90    /// each route.
91    route_idx: ts_bart::Table<smallvec::SmallVec<[PeerId; 2]>>,
92}
93
94impl PeerDb {
95    /// Upsert a node into the peer db.
96    ///
97    /// The [`StableNodeId`] is used as the primary key to identify the node.
98    pub fn upsert(&mut self, new: &Node) -> PeerId {
99        let id = self
100            .index_state
101            .stableid_idx
102            .get(&new.stable_id)
103            .copied()
104            .unwrap_or_else(|| {
105                let id = self.next_id;
106                self.next_id += 1;
107
108                PeerId(id)
109            });
110
111        let old = self.peers.get(&id);
112
113        // no update: same node
114        if old.is_some_and(|x| x == new) {
115            return id;
116        }
117
118        maybe_update_idx(new, old, |x| &x.node_key, &mut self.index_state.nk_idx, id);
119        maybe_update_idx(
120            new,
121            old,
122            |x| &x.stable_id,
123            &mut self.index_state.stableid_idx,
124            id,
125        );
126        maybe_update_idx(new, old, |x| &x.id, &mut self.index_state.control_idx, id);
127
128        maybe_update(
129            new,
130            old,
131            |x| &x.disco_key,
132            &mut self.index_state.disco_idx,
133            |old, idx| {
134                if let Some(key) = &old.disco_key {
135                    // Guarded remove: under netmap churn this entry may already belong to another
136                    // peer (or be gone); only retract our own mapping — never clobber another
137                    // peer's. (was an assert!, which panicked the actor under concurrent joins;
138                    // tsr-gxq)
139                    if idx.get(key).is_some_and(|&x| x == id) {
140                        idx.remove(key);
141                    }
142                }
143            },
144            |new, idx| {
145                if let Some(key) = &new.disco_key {
146                    idx.insert(*key, id);
147                }
148            },
149        );
150
151        // Store both `hostname` and fqdn (no trailing dot) in the `name_idx` index. This _does not_
152        // preserve uniqueness for `hostname`; as documented on external API such as
153        // `tailscale::Device::peer_by_name`, there may be collisions in this field (typically when
154        // nodes are shared into the tailnet with the same name as an existing tailnet device).
155        //
156        // We don't resolve this conflict here and make it the caller's problem to include the fqdn
157        // if there is ambiguity; the index just stores the most recently updated node with a given
158        // hostname.
159        //
160        // Also, this index is overloaded to store both the fqdn and the hostname, but this is
161        // fine since the fqdn always includes `.`, while the hostname never does, so they're always
162        // distinguishable.
163        maybe_update(
164            new,
165            old,
166            |x| (&x.hostname, &x.tailnet),
167            &mut self.index_state.name_idx,
168            |old, idx| {
169                let old_hostname = canon_name(&old.hostname);
170                if idx.get(&old_hostname).is_some_and(|&x| x == id) {
171                    idx.remove(&old_hostname);
172                }
173
174                if let Some(fqdn) = old.fqdn_opt(false) {
175                    // Guarded remove: under netmap churn this entry may already belong to another
176                    // peer (or be gone); only retract our own mapping — never clobber another
177                    // peer's. (was an assert!, which panicked the actor under concurrent joins;
178                    // tsr-gxq)
179                    let k = canon_name(&fqdn);
180                    if idx.get(&k).is_some_and(|&x| x == id) {
181                        idx.remove(&k);
182                    }
183                }
184            },
185            |new, idx| {
186                idx.insert(canon_name(&new.hostname), id);
187
188                if let Some(fqdn) = new.fqdn_opt(false) {
189                    idx.insert(canon_name(&fqdn), id);
190                }
191            },
192        );
193
194        maybe_update(
195            new,
196            old,
197            |x| &x.tailnet_address,
198            &mut self.index_state.ip_idx,
199            |old, idx| {
200                // Guarded remove: under netmap churn these entries may already belong to another
201                // peer (or be gone); only retract our own mapping — never clobber another peer's.
202                // (was an assert!, which panicked the actor under concurrent joins; tsr-gxq)
203                let ipv4: ipnet::IpNet = old.tailnet_address.ipv4.into();
204                let ipv6: ipnet::IpNet = old.tailnet_address.ipv6.into();
205
206                if idx.lookup_prefix_exact(ipv4).is_some_and(|&x| x == id) {
207                    idx.remove(ipv4);
208                }
209                if idx.lookup_prefix_exact(ipv6).is_some_and(|&x| x == id) {
210                    idx.remove(ipv6);
211                }
212            },
213            |new, idx| {
214                idx.insert(new.tailnet_address.ipv4.into(), id);
215                idx.insert(new.tailnet_address.ipv6.into(), id);
216            },
217        );
218
219        maybe_update(
220            new,
221            old,
222            |x| &x.accepted_routes,
223            &mut self.index_state,
224            |old, idx| {
225                for &route in &old.accepted_routes {
226                    idx.remove_route(route, id);
227                }
228            },
229            |new, idx| {
230                for &route in &new.accepted_routes {
231                    idx.route_idx.modify(route, |val| {
232                        if let Some(val) = val {
233                            val.push(id);
234                            return RouteModification::Noop;
235                        }
236
237                        RouteModification::Insert(smallvec::smallvec![id])
238                    });
239                }
240            },
241        );
242
243        self.peers.insert(id, new.clone());
244
245        id
246    }
247
248    /// Remove a peer by a given indexed field.
249    pub fn remove(&mut self, field: &dyn IndexedField) -> Option<(PeerId, Node)> {
250        let id = field.lookup(self)?;
251
252        let node = self.peers.remove(&id)?;
253        self.index_state.remove(id, &node);
254
255        Some((id, node))
256    }
257
258    /// Get the node with the given field.
259    pub fn get(&self, field: &dyn IndexedField) -> Option<(PeerId, &Node)> {
260        let id = field.lookup(self)?;
261        let peer = self.peers.get(&id)?;
262
263        Some((id, peer))
264    }
265
266    /// Get the nodes with the closest matching route.
267    pub fn get_route(&self, route: ipnet::IpNet) -> impl Iterator<Item = (PeerId, &Node)> {
268        // this doesn't use IndexedField because more than one result can be returned
269
270        self.index_state
271            .route_idx
272            .lookup_prefix(route)
273            .into_iter()
274            .flat_map(|x| x.iter())
275            .map(|&id| (id, self.peers.get(&id).unwrap()))
276    }
277
278    /// Check whether there is a peer with the given field in the db.
279    pub fn has(&self, field: &dyn IndexedField) -> Option<PeerId> {
280        field.lookup(self)
281    }
282
283    /// Get a reference to the peer map.
284    pub const fn peers(&self) -> &HashMap<PeerId, Node> {
285        &self.peers
286    }
287
288    /// Remove the nodes in the db that don't satisfy the predicate function.
289    pub fn retain(&mut self, mut predicate: impl FnMut(PeerId, &Node) -> bool) {
290        self.peers.retain(|&id, node| {
291            let retain = predicate(id, node);
292
293            if !retain {
294                self.index_state.remove(id, node);
295            }
296
297            retain
298        });
299    }
300}
301
302impl IndexState {
303    fn remove(&mut self, id: PeerId, node: &Node) {
304        self.nk_idx.remove(&node.node_key);
305        self.stableid_idx.remove(&node.stable_id);
306        self.control_idx.remove(&node.id);
307        self.ip_idx.remove(node.tailnet_address.ipv4.into());
308        self.ip_idx.remove(node.tailnet_address.ipv6.into());
309
310        let hostname = canon_name(&node.hostname);
311        if self.name_idx.get(&hostname).is_some_and(|&x| x == id) {
312            self.name_idx.remove(&hostname);
313        }
314
315        if let Some(fqdn) = node.fqdn_opt(false) {
316            self.name_idx.remove(&canon_name(&fqdn));
317        }
318
319        for route in &node.accepted_routes {
320            self.remove_route(*route, id);
321        }
322
323        if let Some(disco) = &node.disco_key {
324            self.disco_idx.remove(disco);
325        }
326    }
327
328    /// Remove `route` from the `route_idx`.
329    fn remove_route(&mut self, route: ipnet::IpNet, id: PeerId) {
330        self.route_idx.modify(route, |val| match val {
331            Some(val) => {
332                let mut some_matched = false;
333
334                val.retain(|&mut x| {
335                    let ids_match = x == id;
336                    if ids_match {
337                        some_matched = true;
338                    }
339
340                    !ids_match
341                });
342
343                assert!(some_matched);
344
345                if val.is_empty() {
346                    RouteModification::Remove
347                } else {
348                    RouteModification::Noop
349                }
350            }
351            None => RouteModification::Noop,
352        });
353    }
354
355    #[cfg(test)]
356    fn is_empty(&self) -> bool {
357        self.nk_idx.is_empty()
358            && self.stableid_idx.is_empty()
359            && self.control_idx.is_empty()
360            && self.ip_idx.size() == 0
361            && self.name_idx.is_empty()
362            && self.route_idx.size() == 0
363            && self.disco_idx.is_empty()
364    }
365}
366
367/// Attempt to update the `idx` with the `new` node.
368///
369/// The `accessor` selects a set of fields to check (by `PartialEq`) for whether the `new`
370/// node has changed compared to the `old` one:
371///
372/// - If the value returned by `accessor` is the same between `new` and `old`, nothing
373///   happens.
374/// - If the value has changed and `old` is `Some`, `remove(old, idx)` is called.
375/// - If the value has changed, `insert(new, idx)` is called.
376fn maybe_update<'n, T, Idx>(
377    new: &'n Node,
378    old: Option<&'n Node>,
379    accessor: impl Fn(&'n Node) -> T,
380    idx: &mut Idx,
381    mut remove: impl FnMut(&'n Node, &mut Idx),
382    mut insert: impl FnMut(&'n Node, &mut Idx),
383) where
384    T: PartialEq + 'n,
385{
386    match old {
387        Some(old) if accessor(old) == accessor(new) => {
388            return;
389        }
390        Some(x) => {
391            remove(x, idx);
392        }
393        None => {}
394    }
395
396    insert(new, idx)
397}
398
399/// Specialization of [`maybe_update`] to work on [`Index`].
400fn maybe_update_idx<T>(
401    new: &Node,
402    old: Option<&Node>,
403    accessor: impl Fn(&Node) -> &T,
404    idx: &mut Index<T>,
405    new_id: PeerId,
406) where
407    T: Eq + Hash + Clone,
408{
409    maybe_update(
410        new,
411        old,
412        &accessor,
413        idx,
414        |old, idx| {
415            // Guarded remove: under netmap churn this entry may already belong to another peer
416            // (or be gone); only retract our own mapping — never clobber another peer's. (was an
417            // assert!, which panicked the actor under concurrent joins; tsr-gxq)
418            if idx.get(accessor(old)).is_some_and(|&x| x == new_id) {
419                idx.remove(accessor(old));
420            }
421        },
422        |new, idx| {
423            idx.insert(accessor(new).clone(), new_id);
424        },
425    )
426}
427
428impl IndexedField for PeerId {
429    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
430        if db.peers.contains_key(self) {
431            Some(*self)
432        } else {
433            None
434        }
435    }
436}
437
438impl IndexedField for NodePublicKey {
439    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
440        db.index_state.nk_idx.get(self).copied()
441    }
442}
443
444impl IndexedField for DiscoPublicKey {
445    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
446        db.index_state.disco_idx.get(self).copied()
447    }
448}
449
450impl IndexedField for StableNodeId {
451    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
452        db.index_state.stableid_idx.get(self).copied()
453    }
454}
455
456impl IndexedField for ts_control::NodeId {
457    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
458        db.index_state.control_idx.get(self).copied()
459    }
460}
461
462impl IndexedField for PeerName {
463    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
464        db.index_state.name_idx.get(&canon_name(self)).copied()
465    }
466}
467
468impl IndexedField for &str {
469    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
470        db.index_state.name_idx.get(&canon_name(self)).copied()
471    }
472}
473
474impl IndexedField for IpAddr {
475    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
476        db.index_state.ip_idx.lookup(*self).copied()
477    }
478}
479
480#[cfg(test)]
481mod test {
482    use std::{
483        collections::{HashMap, HashSet},
484        net::{Ipv4Addr, Ipv6Addr, SocketAddr},
485        num::NonZeroU32,
486    };
487
488    use proptest::{
489        collection::{hash_set, vec},
490        prelude::any,
491        strategy::Strategy,
492    };
493    use rand::{
494        RngExt,
495        distr::{Alphanumeric, SampleString},
496    };
497    use ts_control::TailnetAddress;
498
499    use super::*;
500
501    fn rand_string(rng: &mut dyn rand::Rng, max_len: usize) -> String {
502        let len = rng.random_range(1..max_len);
503        Alphanumeric.sample_string(rng, len)
504    }
505
506    fn rand_route(rng: &mut dyn rand::Rng) -> ipnet::IpNet {
507        if rng.random::<bool>() {
508            let ip = rand_ipv4(rng);
509            ipnet::Ipv4Net::new(ip, rand::random_range(0..=32))
510                .unwrap()
511                .trunc()
512                .into()
513        } else {
514            let ip = rand_ipv6(rng);
515            ipnet::Ipv6Net::new(ip, rand::random_range(0..=128))
516                .unwrap()
517                .trunc()
518                .into()
519        }
520    }
521
522    fn rand_ipv4(rng: &mut dyn rand::Rng) -> Ipv4Addr {
523        Ipv4Addr::from_octets(rng.random::<[u8; 4]>())
524    }
525
526    fn rand_ipv6(rng: &mut dyn rand::Rng) -> Ipv6Addr {
527        Ipv6Addr::from_segments(rng.random::<[u16; 8]>())
528    }
529
530    fn rand_node() -> Node {
531        let mut rng = rand::rng();
532        let tailnet_address = TailnetAddress {
533            ipv4: rand_ipv4(&mut rng).into(),
534            ipv6: rand_ipv6(&mut rng).into(),
535        };
536
537        Node {
538            stable_id: StableNodeId(rand_string(&mut rng, 32)),
539            addresses: vec![tailnet_address.ipv4.into(), tailnet_address.ipv6.into()],
540            tailnet_address,
541            node_key: rng.random::<[u8; 32]>().into(),
542            key_signature: vec![],
543            disco_key: rng
544                .random::<bool>()
545                .then_some(rng.random::<[u8; 32]>().into()),
546            machine_key: rng
547                .random::<bool>()
548                .then_some(rng.random::<[u8; 32]>().into()),
549            id: rng.random(),
550            accepted_routes: (0..rng.random_range(0..32))
551                .map(|_| rand_route(&mut rng))
552                .collect(),
553
554            hostname: rand_string(&mut rng, 32),
555            user_id: rng.random(),
556            tailnet: rng.random::<bool>().then_some(rand_string(&mut rng, 32)),
557
558            node_key_expiry: None,
559            online: None,
560            last_seen: None,
561            underlay_addresses: vec![],
562            derp_region: rng
563                .random::<bool>()
564                .then_some(ts_derp::RegionId(rng.random())),
565
566            tags: (0..rng.random_range(0..8))
567                .map(|_| rand_string(&mut rng, 32))
568                .collect(),
569
570            cap: Default::default(),
571            cap_map: Default::default(),
572            peerapi_port: None,
573            peerapi_dns_proxy: false,
574            is_wireguard_only: false,
575            exit_node_dns_resolvers: vec![],
576            peer_relay: false,
577            ssh_host_keys: vec![],
578            service_vips: Default::default(),
579            unsigned_peer_api_only: false,
580        }
581    }
582
583    fn validate_indices(db: &PeerDb, node: &Node, id: PeerId) {
584        let ipv4 = IpAddr::from(node.tailnet_address.ipv4.addr());
585        let ipv6 = IpAddr::from(node.tailnet_address.ipv6.addr());
586        let fqdn = node.fqdn_opt(false);
587
588        let mut keys: Vec<&dyn IndexedField> =
589            vec![&id, &node.node_key, &node.stable_id, &node.id, &ipv4, &ipv6];
590
591        if let Some(disco) = &node.disco_key {
592            keys.push(disco);
593        }
594
595        if let Some(fqdn) = &fqdn {
596            keys.push(fqdn);
597        }
598
599        for k in keys {
600            let lookup_id = k.lookup(db).unwrap();
601            assert_eq!(lookup_id, id, "wrong id for key {k:?}");
602
603            let (lookup_id, lookup_node) = db.get(k).unwrap();
604            assert_eq!(lookup_id, id, "wrong id for key {k:?}");
605            assert_eq!(lookup_node, node, "wrong node for key {k:?}");
606        }
607
608        // We don't know if the hostname collides, but it should resolve to something
609        node.hostname.lookup(db).unwrap();
610
611        for &route in &node.accepted_routes {
612            // Generically we don't actually know if this node has the most specific match for this
613            // route, but there should at least be one match, and all matches should have at least
614            // one route that (inclusively) subsets our route.
615
616            let routes = db.get_route(route).collect::<Vec<_>>();
617            assert!(!routes.is_empty());
618
619            for (found_id, found_node) in routes {
620                if found_id == id {
621                    assert_eq!(found_node, node);
622                    break;
623                }
624
625                let has_subset = found_node
626                    .accepted_routes
627                    .iter()
628                    .any(|found_route| route.contains(found_route));
629
630                assert!(has_subset);
631            }
632        }
633    }
634
635    /// Assert that the node's routes are all present as the most specific routes in the
636    /// db.
637    fn assert_has_routes_exact(db: &PeerDb, node: &Node, id: PeerId) {
638        for &route in &node.accepted_routes {
639            let match_exists = db
640                .get_route(route)
641                .any(|(found_id, found_node)| found_id == id && found_node == node);
642
643            assert!(match_exists);
644        }
645    }
646
647    #[test]
648    fn test_indices() {
649        let mut db = PeerDb::default();
650        let node = rand_node();
651        let id = db.upsert(&node);
652
653        validate_indices(&db, &node, id);
654        assert_has_routes_exact(&db, &node, id);
655    }
656
657    #[test]
658    fn test_names() {
659        let mut db = PeerDb::default();
660
661        let node1 = Node {
662            hostname: "test".to_string(),
663            tailnet: Some("ts.net".to_string()),
664            ..rand_node()
665        };
666        let node2 = Node {
667            hostname: "test".to_string(),
668            tailnet: Some("ts2.net".to_string()),
669            ..rand_node()
670        };
671        let node3 = Node {
672            hostname: "test".to_string(),
673            tailnet: None,
674            ..rand_node()
675        };
676
677        let id1 = db.upsert(&node1);
678        let id2 = db.upsert(&node2);
679        let id3 = db.upsert(&node3);
680
681        let nodes = [(id1, &node1), (id2, &node2), (id3, &node3)];
682
683        for (id, node) in &nodes {
684            validate_indices(&db, node, *id);
685        }
686
687        let (id, node) = db.get(&"test").unwrap();
688        assert!(nodes.iter().any(|(x, _node)| *x == id));
689
690        for &(x, curnode) in &nodes {
691            if x == id {
692                assert_eq!(node, curnode);
693            } else {
694                assert_ne!(node, curnode);
695            }
696        }
697
698        let (id, node) = db.get(&"test.ts.net").unwrap();
699        assert_eq!(id, id1);
700        assert_eq!(node, &node1);
701
702        let (id, node) = db.get(&"test.ts2.net").unwrap();
703        assert_eq!(id, id2);
704        assert_eq!(node, &node2);
705    }
706
707    #[test]
708    fn test_name_lookup_is_canonicalized() {
709        // MagicDNS names are case-insensitive and may carry a trailing dot; lookups must match
710        // regardless of the caller's casing or trailing dot (tsnet `canonMapKey` parity).
711        let mut db = PeerDb::default();
712
713        let node = Node {
714            hostname: "MixedCase".to_string(),
715            tailnet: Some("Tail-Scale.ts.net".to_string()),
716            ..rand_node()
717        };
718        let id = db.upsert(&node);
719
720        // bare hostname: any case, no tailnet component
721        assert_eq!(db.get(&"mixedcase").unwrap().0, id);
722        assert_eq!(db.get(&"MIXEDCASE").unwrap().0, id);
723
724        // fqdn: any case, with and without trailing dot
725        assert_eq!(db.get(&"mixedcase.tail-scale.ts.net").unwrap().0, id);
726        assert_eq!(db.get(&"MixedCase.Tail-Scale.TS.NET").unwrap().0, id);
727        assert_eq!(db.get(&"mixedcase.tail-scale.ts.net.").unwrap().0, id);
728
729        // removal must also canonicalize, leaving no dangling index entries
730        db.remove(&id);
731        assert!(db.get(&"mixedcase").is_none());
732        assert!(db.get(&"mixedcase.tail-scale.ts.net").is_none());
733        assert!(db.index_state.is_empty());
734    }
735
736    #[test]
737    fn disco_key_reassigned_across_peers_no_panic() {
738        // Under netmap churn, control can transiently move a disco_key from one peer to another
739        // and then update the original peer. Before the fix, the old-value removal asserted the
740        // disco entry still mapped back to the original peer and panicked the actor (tsr-gxq).
741        let mut db = PeerDb::default();
742
743        let disco: DiscoPublicKey = [7u8; 32].into();
744
745        let node_a = Node {
746            disco_key: Some(disco),
747            ..rand_node()
748        };
749        let id_a = db.upsert(&node_a);
750
751        // B claims the same disco_key (churn / transient reuse). The disco index now points at B.
752        let node_b = Node {
753            disco_key: Some(disco),
754            ..rand_node()
755        };
756        let id_b = db.upsert(&node_b);
757        assert_ne!(id_a, id_b);
758
759        // Re-upsert A with no disco_key. The old-value removal must not panic even though the
760        // disco entry now belongs to B.
761        let node_a2 = Node {
762            disco_key: None,
763            ..node_a.clone()
764        };
765        let id_a2 = db.upsert(&node_a2);
766        assert_eq!(id_a, id_a2);
767
768        // The disco index should still resolve to the last writer (B), unharmed.
769        assert_eq!(disco.lookup(&db), Some(id_b));
770    }
771
772    #[test]
773    fn ip_reassigned_across_peers_no_panic() {
774        // Two peers transiently share a tailnet IP during churn, then the original changes IPs.
775        // Before the fix, the ip_idx old-value removal asserted ownership and panicked (tsr-gxq).
776        let mut db = PeerDb::default();
777
778        let shared = TailnetAddress {
779            ipv4: Ipv4Addr::new(100, 64, 0, 1).into(),
780            ipv6: Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 1).into(),
781        };
782
783        let node_a = Node {
784            addresses: vec![shared.ipv4.into(), shared.ipv6.into()],
785            tailnet_address: shared.clone(),
786            ..rand_node()
787        };
788        let id_a = db.upsert(&node_a);
789
790        // B claims the same tailnet IPs (churn). The ip index now points at B.
791        let node_b = Node {
792            addresses: vec![shared.ipv4.into(), shared.ipv6.into()],
793            tailnet_address: shared.clone(),
794            ..rand_node()
795        };
796        let id_b = db.upsert(&node_b);
797        assert_ne!(id_a, id_b);
798
799        // Re-upsert A with different IPs. The old-value removal must not panic even though the
800        // shared IP entries now belong to B.
801        let renumbered = TailnetAddress {
802            ipv4: Ipv4Addr::new(100, 64, 0, 2).into(),
803            ipv6: Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 2).into(),
804        };
805        let node_a2 = Node {
806            addresses: vec![renumbered.ipv4.into(), renumbered.ipv6.into()],
807            tailnet_address: renumbered,
808            ..node_a.clone()
809        };
810        let id_a2 = db.upsert(&node_a2);
811        assert_eq!(id_a, id_a2);
812
813        // The shared IPs still resolve to the last writer (B), unharmed.
814        assert_eq!(
815            IpAddr::from(Ipv4Addr::new(100, 64, 0, 1)).lookup(&db),
816            Some(id_b)
817        );
818        // A's new IP resolves to A.
819        assert_eq!(
820            IpAddr::from(Ipv4Addr::new(100, 64, 0, 2)).lookup(&db),
821            Some(id_a)
822        );
823    }
824
825    #[test]
826    fn node_key_or_stableid_churn_no_panic() {
827        // Exercises the generic `maybe_update_idx` path (node_key / stable_id / control_idx). A
828        // peer re-registering with a node_key that another peer transiently claimed must not panic
829        // the actor on the old-value removal (tsr-gxq).
830        let mut db = PeerDb::default();
831
832        let key: NodePublicKey = [9u8; 32].into();
833
834        let node_a = Node {
835            node_key: key,
836            ..rand_node()
837        };
838        let id_a = db.upsert(&node_a);
839
840        // B claims the same node_key (churn). The nk index now points at B.
841        let node_b = Node {
842            node_key: key,
843            ..rand_node()
844        };
845        let id_b = db.upsert(&node_b);
846        assert_ne!(id_a, id_b);
847
848        // Re-upsert A with a fresh node_key. The old-value removal must not panic even though the
849        // old node_key entry now belongs to B.
850        let node_a2 = Node {
851            node_key: [10u8; 32].into(),
852            ..node_a.clone()
853        };
854        let id_a2 = db.upsert(&node_a2);
855        assert_eq!(id_a, id_a2);
856
857        // The churned node_key still resolves to B; A's fresh key resolves to A.
858        assert_eq!(key.lookup(&db), Some(id_b));
859        assert_eq!(NodePublicKey::from([10u8; 32]).lookup(&db), Some(id_a));
860    }
861
862    proptest::prop_compose! {
863        fn ipv4net()(
864            addr: Ipv4Addr,
865            pfx in 0u8..=32,
866        ) -> ipnet::Ipv4Net {
867            ipnet::Ipv4Net::new(addr, pfx).unwrap().trunc()
868        }
869    }
870
871    proptest::prop_compose! {
872        fn ipv6net()(
873            addr: Ipv6Addr,
874            pfx in 0u8..=32,
875        ) -> ipnet::Ipv6Net {
876            ipnet::Ipv6Net::new(addr, pfx).unwrap().trunc()
877        }
878    }
879
880    fn ipnet() -> impl Strategy<Value = ipnet::IpNet> {
881        proptest::prop_oneof![
882            ipv4net().prop_map(ipnet::IpNet::from),
883            ipv6net().prop_map(ipnet::IpNet::from)
884        ]
885    }
886
887    proptest::prop_compose! {
888        // Lowercase only: names are canonicalized (case-insensitively) by `canon_name`, so the
889        // `hash_set` uniqueness the node generators rely on must hold in canonical form too.
890        // Mixed-case segments could collide after canonicalization and break index assertions.
891        fn domain_segment()(
892            seg in "[a-z][a-z0-9]*"
893        ) -> String {
894            seg
895        }
896    }
897
898    proptest::prop_compose! {
899        fn domain(max_count: usize)(
900            segs in proptest::collection::vec(domain_segment(), 0..max_count)
901        ) -> String {
902            segs.join(".")
903        }
904    }
905
906    type Key = [u8; 32];
907
908    proptest::prop_compose! {
909        // This is set up this way to ensure uniqueness among all the required-unique keys in a
910        // node. The `hash_set`s ensure that all ids AND stable ids AND node keys etc. are unique.
911        fn nodes(n: usize)(
912            id in hash_set(any::<i64>(), n),
913            stable_id in hash_set(".+", n),
914            tags in vec(hash_set(".+", 0..32), n),
915            accepted_routes in vec(hash_set(ipnet(), 0..32), n),
916            node_key in hash_set(any::<Key>(), n),
917            machine_key in vec(any::<Option<Key>>(), n),
918            disco_key in vec(any::<Option<Key>>(), n),
919            ipv4 in hash_set(any::<Ipv4Addr>(), n),
920            ipv6 in hash_set(any::<Ipv6Addr>(), n),
921            name in hash_set(domain_segment(), n),
922            tailnet in vec(domain(5), n),
923            has_tailnet in vec(any::<bool>(), n),
924            derp_region in vec(any::<Option<NonZeroU32>>(), n),
925            underlay_addrs in vec(any::<HashSet<SocketAddr>>(), n),
926        ) -> Vec<Node> {
927            itertools::izip![
928                id,
929                stable_id,
930                tags,
931                accepted_routes,
932                node_key,
933                machine_key,
934                disco_key,
935                ipv4,
936                ipv6,
937                name,
938                tailnet,
939                has_tailnet,
940                derp_region,
941                underlay_addrs,
942            ].map(|(
943                id,
944                stable_id,
945                tags,
946                mut accepted_routes,
947                node_key,
948                machine_key,
949                disco_key,
950                ipv4,
951                ipv6,
952                name,
953                tailnet,
954                has_tailnet,
955                derp_region,
956                underlay_addrs,
957            )| {
958                accepted_routes.insert(ipnet::Ipv4Net::from(ipv4).into());
959                accepted_routes.insert(ipnet::Ipv6Net::from(ipv6).into());
960
961                Node {
962                    id,
963                    stable_id: StableNodeId(stable_id),
964
965                    hostname: name,
966                    user_id: 0,
967                    tailnet: has_tailnet.then_some(tailnet),
968
969                    node_key: node_key.into(),
970                    key_signature: vec![],
971                    disco_key: disco_key.map(Into::into),
972                    machine_key: machine_key.map(Into::into),
973
974                    node_key_expiry: None,
975            online: None,
976            last_seen: None,
977
978                    addresses: vec![
979                        ipnet::IpNet::V4(ipv4.into()),
980                        ipnet::IpNet::V6(ipv6.into()),
981                    ],
982                    tailnet_address: TailnetAddress {
983                        ipv4: ipv4.into(),
984                        ipv6: ipv6.into(),
985                    },
986                    tags: tags.into_iter().collect(),
987
988                    derp_region: derp_region.map(ts_derp::RegionId),
989
990                    accepted_routes: accepted_routes.into_iter().collect(),
991                    underlay_addresses: underlay_addrs.into_iter().collect(),
992
993                    cap: Default::default(),
994                    cap_map: Default::default(),
995                    peerapi_port: None,
996                    peerapi_dns_proxy: false,
997                    is_wireguard_only: false,
998                    exit_node_dns_resolvers: vec![],
999                    peer_relay: false,
1000                    ssh_host_keys: vec![],
1001                    service_vips: Default::default(),
1002                    unsigned_peer_api_only: false,
1003                }
1004            })
1005            .collect()
1006        }
1007    }
1008
1009    proptest::proptest! {
1010        #[test]
1011        fn prop_one_node_indices(mut nodes in nodes(1)) {
1012            let node = nodes.pop().unwrap();
1013
1014            let mut db = PeerDb::default();
1015            let id = db.upsert(&node);
1016
1017            validate_indices(&db, &node, id);
1018            assert_has_routes_exact(&db, &node, id);
1019        }
1020
1021        #[test]
1022        fn prop_many_nodes_indexed(nodes in nodes(16)) {
1023            let mut db = PeerDb::default();
1024
1025            let mut nodes_by_id = HashMap::new();
1026
1027            for node in &nodes {
1028                let id = db.upsert(node);
1029                nodes_by_id.insert(id, node.clone());
1030            }
1031
1032            for (id, node) in &nodes_by_id {
1033                validate_indices(&db, node, *id);
1034            }
1035        }
1036
1037        #[test]
1038        fn prop_remove(nodes in nodes(16)) {
1039            let mut db = PeerDb::default();
1040
1041            let mut ids = vec![];
1042
1043            for node in &nodes {
1044                ids.push((db.upsert(node), node));
1045            }
1046
1047            for (id, node) in ids {
1048                let (removed_id, removed_node) = db.remove(&id).unwrap();
1049
1050                proptest::prop_assert_eq!(removed_id, id);
1051                proptest::prop_assert_eq!(&removed_node, node);
1052            }
1053
1054            proptest::prop_assert!(db.peers.is_empty());
1055            proptest::prop_assert!(db.index_state.is_empty());
1056        }
1057    }
1058}