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