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                    delete_if_owned(idx, key, id);
167                }
168            },
169            |new, idx| {
170                if let Some(key) = &new.disco_key {
171                    idx.insert(*key, id);
172                }
173            },
174        );
175
176        // Store both `hostname` and fqdn (no trailing dot) in the `name_idx` index. This _does not_
177        // preserve uniqueness for `hostname`; as documented on external API such as
178        // `tailscale::Device::peer_by_name`, there may be collisions in this field (typically when
179        // nodes are shared into the tailnet with the same name as an existing tailnet device).
180        //
181        // We don't resolve this conflict here and make it the caller's problem to include the fqdn
182        // if there is ambiguity; the index just stores the most recently updated node with a given
183        // hostname.
184        //
185        // Also, this index is overloaded to store both the fqdn and the hostname, but this is
186        // fine since the fqdn always includes `.`, while the hostname never does, so they're always
187        // distinguishable.
188        maybe_update(
189            new,
190            old,
191            |x| (&x.hostname, &x.tailnet),
192            &mut self.index_state.name_idx,
193            |old, idx| {
194                delete_if_owned(idx, &canon_name(&old.hostname), id);
195
196                if let Some(fqdn) = old.fqdn_opt(false) {
197                    delete_if_owned(idx, &canon_name(&fqdn), id);
198                }
199            },
200            |new, idx| {
201                idx.insert(canon_name(&new.hostname), id);
202
203                if let Some(fqdn) = new.fqdn_opt(false) {
204                    idx.insert(canon_name(&fqdn), id);
205                }
206            },
207        );
208
209        maybe_update(
210            new,
211            old,
212            |x| &x.tailnet_address,
213            &mut self.index_state.ip_idx,
214            |old, idx| {
215                delete_ip_if_owned(idx, old.tailnet_address.ipv4.into(), id);
216                delete_ip_if_owned(idx, old.tailnet_address.ipv6.into(), id);
217            },
218            |new, idx| {
219                idx.insert(new.tailnet_address.ipv4.into(), id);
220                idx.insert(new.tailnet_address.ipv6.into(), id);
221            },
222        );
223
224        maybe_update(
225            new,
226            old,
227            |x| &x.accepted_routes,
228            &mut self.index_state,
229            |old, idx| {
230                for &route in &old.accepted_routes {
231                    idx.remove_route(route, id);
232                }
233            },
234            |new, idx| {
235                for &route in &new.accepted_routes {
236                    idx.route_idx.modify(route, |val| {
237                        if let Some(val) = val {
238                            val.push(id);
239                            return RouteModification::Noop;
240                        }
241
242                        RouteModification::Insert(smallvec::smallvec![id])
243                    });
244                }
245            },
246        );
247
248        self.peers.insert(id, new.clone());
249
250        id
251    }
252
253    /// Remove a peer by a given indexed field.
254    pub fn remove(&mut self, field: &dyn IndexedField) -> Option<(PeerId, Node)> {
255        let id = field.lookup(self)?;
256
257        let node = self.peers.remove(&id)?;
258        self.index_state.remove(id, &node);
259
260        Some((id, node))
261    }
262
263    /// Get the node with the given field.
264    pub fn get(&self, field: &dyn IndexedField) -> Option<(PeerId, &Node)> {
265        let id = field.lookup(self)?;
266        let peer = self.peers.get(&id)?;
267
268        Some((id, peer))
269    }
270
271    /// Get the nodes with the closest matching route.
272    pub fn get_route(&self, route: ipnet::IpNet) -> impl Iterator<Item = (PeerId, &Node)> {
273        // this doesn't use IndexedField because more than one result can be returned
274
275        self.index_state
276            .route_idx
277            .lookup_prefix(route)
278            .into_iter()
279            .flat_map(|x| x.iter())
280            .map(|&id| (id, self.peers.get(&id).unwrap()))
281    }
282
283    /// Check whether there is a peer with the given field in the db.
284    pub fn has(&self, field: &dyn IndexedField) -> Option<PeerId> {
285        field.lookup(self)
286    }
287
288    /// Resolve an inbound disco frame's sender key to the peer that owns it, accepting **either**
289    /// of that peer's two known disco keys — Go [`endpoint.checkAndUpdateDiscoKey`].
290    ///
291    /// The active key is tried first, so a key that is one peer's active key and another peer's
292    /// stale inactive one resolves to the peer that is actually using it. A key belonging to
293    /// neither slot of any peer returns `None` and must be refused by the caller: that refusal is
294    /// what stops an unknown disco key from opening a path or being attributed to a peer.
295    ///
296    /// Two peers can transiently claim the same key under netmap churn (see
297    /// `disco_key_reassigned_across_peers_no_panic`); like every other index here the answer is the
298    /// last writer's, never a panic.
299    ///
300    /// [`endpoint.checkAndUpdateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
301    pub fn peer_by_known_disco_key(
302        &self,
303        key: &DiscoPublicKey,
304    ) -> Option<(PeerId, &Node, DiscoKeyMatch)> {
305        let (id, matched) = match self.index_state.disco_idx.get(key) {
306            Some(&id) => (id, DiscoKeyMatch::Active),
307            None => (
308                self.index_state.inactive_disco_idx.get(key).copied()?,
309                DiscoKeyMatch::Inactive,
310            ),
311        };
312
313        Some((id, self.peers.get(&id)?, matched))
314    }
315
316    /// Register (or clear) the peer's known-but-inactive disco key, so ingress under it still
317    /// resolves to the peer.
318    ///
319    /// A control [`Node`] carries a single disco key, so the second slot of Go's `endpointDisco`
320    /// cannot come in through [`upsert`](Self::upsert); the peer tracker — which owns that state —
321    /// writes it here immediately after each upsert. Passing `None` retracts the entry, which is
322    /// what a peer that has only ever had one key needs.
323    pub fn set_inactive_disco_key(&mut self, id: PeerId, key: Option<DiscoPublicKey>) {
324        let idx = &mut self.index_state;
325
326        // Guarded remove, as everywhere else: only retract a mapping that is still ours.
327        if let Some(previous) = idx.inactive_disco.remove(&id)
328            && idx
329                .inactive_disco_idx
330                .get(&previous)
331                .is_some_and(|&x| x == id)
332        {
333            idx.inactive_disco_idx.remove(&previous);
334        }
335
336        if let Some(key) = key {
337            idx.inactive_disco.insert(id, key);
338            idx.inactive_disco_idx.insert(key, id);
339        }
340    }
341
342    /// Get a reference to the peer map.
343    pub const fn peers(&self) -> &HashMap<PeerId, Node> {
344        &self.peers
345    }
346
347    /// Remove the nodes in the db that don't satisfy the predicate function.
348    pub fn retain(&mut self, mut predicate: impl FnMut(PeerId, &Node) -> bool) {
349        self.peers.retain(|&id, node| {
350            let retain = predicate(id, node);
351
352            if !retain {
353                self.index_state.remove(id, node);
354            }
355
356            retain
357        });
358    }
359}
360
361impl IndexState {
362    /// Retract every index entry a departing (or replaced) peer holds.
363    ///
364    /// Every retraction is guarded by [`delete_if_owned`]: an entry is dropped only while it still
365    /// maps to `id`. Control can hand a churning peer's tailnet IP, MagicDNS name, node key or
366    /// disco key to a NEWER peer and deliver the new peer's upsert before the old peer's removal —
367    /// and `PeerTracker::apply_peer_update` applies a delta's upserts before its removals, so
368    /// that is the ordering this tree always uses. An unconditional delete here would wipe the
369    /// live owner's entry, leaving a peer that is present and handshaking unresolvable by IP
370    /// (whois, peerapi source checks) or by disco key until the next full netmap.
371    fn remove(&mut self, id: PeerId, node: &Node) {
372        delete_if_owned(&mut self.nk_idx, &node.node_key, id);
373        delete_if_owned(&mut self.stableid_idx, &node.stable_id, id);
374        delete_if_owned(&mut self.control_idx, &node.id, id);
375
376        delete_ip_if_owned(&mut self.ip_idx, node.tailnet_address.ipv4.into(), id);
377        delete_ip_if_owned(&mut self.ip_idx, node.tailnet_address.ipv6.into(), id);
378
379        delete_if_owned(&mut self.name_idx, &canon_name(&node.hostname), id);
380
381        if let Some(fqdn) = node.fqdn_opt(false) {
382            delete_if_owned(&mut self.name_idx, &canon_name(&fqdn), id);
383        }
384
385        for route in &node.accepted_routes {
386            self.remove_route(*route, id);
387        }
388
389        if let Some(disco) = &node.disco_key {
390            delete_if_owned(&mut self.disco_idx, disco, id);
391        }
392
393        if let Some(key) = self.inactive_disco.remove(&id) {
394            delete_if_owned(&mut self.inactive_disco_idx, &key, id);
395        }
396    }
397
398    /// Remove `route` from the `route_idx`.
399    fn remove_route(&mut self, route: ipnet::IpNet, id: PeerId) {
400        self.route_idx.modify(route, |val| match val {
401            Some(val) => {
402                let mut some_matched = false;
403
404                val.retain(|&mut x| {
405                    let ids_match = x == id;
406                    if ids_match {
407                        some_matched = true;
408                    }
409
410                    !ids_match
411                });
412
413                assert!(some_matched);
414
415                if val.is_empty() {
416                    RouteModification::Remove
417                } else {
418                    RouteModification::Noop
419                }
420            }
421            None => RouteModification::Noop,
422        });
423    }
424
425    #[cfg(test)]
426    fn is_empty(&self) -> bool {
427        self.nk_idx.is_empty()
428            && self.stableid_idx.is_empty()
429            && self.control_idx.is_empty()
430            && self.ip_idx.size() == 0
431            && self.name_idx.is_empty()
432            && self.route_idx.size() == 0
433            && self.disco_idx.is_empty()
434            && self.inactive_disco_idx.is_empty()
435            && self.inactive_disco.is_empty()
436    }
437}
438
439/// Retract `key` from `idx`, but only while it still maps to `id`.
440///
441/// The one rule every index retraction in this file obeys — Go's `deleteIfOwned`
442/// (`ipn/ipnlocal/node_backend.go`). Under netmap churn an entry a peer used to own may already
443/// have been re-pointed at a NEWER peer that inherited its address, name or key; retracting it
444/// unconditionally would evict the live owner and strand a peer that is present and handshaking.
445/// Never clobber another peer's entry — only ever your own. (An earlier version asserted ownership
446/// instead and panicked the actor under concurrent joins.)
447fn delete_if_owned<T: Eq + Hash>(idx: &mut Index<T>, key: &T, id: PeerId) {
448    if idx.get(key).is_some_and(|&x| x == id) {
449        idx.remove(key);
450    }
451}
452
453/// [`delete_if_owned`] for the prefix-keyed IP index, which is a routing table rather than a map.
454///
455/// Matches on the EXACT prefix (`lookup_prefix_exact`), never a longest-prefix match: a peer owns
456/// the entry for its own address, not for whatever covering route happens to answer a lookup.
457fn delete_ip_if_owned(idx: &mut ts_bart::Table<PeerId>, prefix: ipnet::IpNet, id: PeerId) {
458    if idx.lookup_prefix_exact(prefix).is_some_and(|&x| x == id) {
459        idx.remove(prefix);
460    }
461}
462
463/// Attempt to update the `idx` with the `new` node.
464///
465/// The `accessor` selects a set of fields to check (by `PartialEq`) for whether the `new`
466/// node has changed compared to the `old` one:
467///
468/// - If the value returned by `accessor` is the same between `new` and `old`, nothing
469///   happens.
470/// - If the value has changed and `old` is `Some`, `remove(old, idx)` is called.
471/// - If the value has changed, `insert(new, idx)` is called.
472fn maybe_update<'n, T, Idx>(
473    new: &'n Node,
474    old: Option<&'n Node>,
475    accessor: impl Fn(&'n Node) -> T,
476    idx: &mut Idx,
477    mut remove: impl FnMut(&'n Node, &mut Idx),
478    mut insert: impl FnMut(&'n Node, &mut Idx),
479) where
480    T: PartialEq + 'n,
481{
482    match old {
483        Some(old) if accessor(old) == accessor(new) => {
484            return;
485        }
486        Some(x) => {
487            remove(x, idx);
488        }
489        None => {}
490    }
491
492    insert(new, idx)
493}
494
495/// Specialization of [`maybe_update`] to work on [`Index`].
496fn maybe_update_idx<T>(
497    new: &Node,
498    old: Option<&Node>,
499    accessor: impl Fn(&Node) -> &T,
500    idx: &mut Index<T>,
501    new_id: PeerId,
502) where
503    T: Eq + Hash + Clone,
504{
505    maybe_update(
506        new,
507        old,
508        &accessor,
509        idx,
510        |old, idx| {
511            delete_if_owned(idx, accessor(old), new_id);
512        },
513        |new, idx| {
514            idx.insert(accessor(new).clone(), new_id);
515        },
516    )
517}
518
519impl IndexedField for PeerId {
520    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
521        if db.peers.contains_key(self) {
522            Some(*self)
523        } else {
524            None
525        }
526    }
527}
528
529impl IndexedField for NodePublicKey {
530    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
531        db.index_state.nk_idx.get(self).copied()
532    }
533}
534
535impl IndexedField for DiscoPublicKey {
536    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
537        db.index_state.disco_idx.get(self).copied()
538    }
539}
540
541impl IndexedField for StableNodeId {
542    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
543        db.index_state.stableid_idx.get(self).copied()
544    }
545}
546
547impl IndexedField for ts_control::NodeId {
548    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
549        db.index_state.control_idx.get(self).copied()
550    }
551}
552
553impl IndexedField for PeerName {
554    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
555        db.index_state.name_idx.get(&canon_name(self)).copied()
556    }
557}
558
559impl IndexedField for &str {
560    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
561        db.index_state.name_idx.get(&canon_name(self)).copied()
562    }
563}
564
565impl IndexedField for IpAddr {
566    fn lookup(&self, db: &PeerDb) -> Option<PeerId> {
567        db.index_state.ip_idx.lookup(*self).copied()
568    }
569}
570
571#[cfg(test)]
572mod test {
573    use std::{
574        collections::{HashMap, HashSet},
575        net::{Ipv4Addr, Ipv6Addr, SocketAddr},
576        num::NonZeroU32,
577    };
578
579    use proptest::{
580        collection::{hash_set, vec},
581        prelude::any,
582        strategy::Strategy,
583    };
584    use rand::{
585        RngExt,
586        distr::{Alphanumeric, SampleString},
587    };
588    use ts_control::TailnetAddress;
589
590    use super::*;
591
592    fn rand_string(rng: &mut dyn rand::Rng, max_len: usize) -> String {
593        let len = rng.random_range(1..max_len);
594        Alphanumeric.sample_string(rng, len)
595    }
596
597    fn rand_route(rng: &mut dyn rand::Rng) -> ipnet::IpNet {
598        if rng.random::<bool>() {
599            let ip = rand_ipv4(rng);
600            ipnet::Ipv4Net::new(ip, rand::random_range(0..=32))
601                .unwrap()
602                .trunc()
603                .into()
604        } else {
605            let ip = rand_ipv6(rng);
606            ipnet::Ipv6Net::new(ip, rand::random_range(0..=128))
607                .unwrap()
608                .trunc()
609                .into()
610        }
611    }
612
613    fn rand_ipv4(rng: &mut dyn rand::Rng) -> Ipv4Addr {
614        Ipv4Addr::from_octets(rng.random::<[u8; 4]>())
615    }
616
617    fn rand_ipv6(rng: &mut dyn rand::Rng) -> Ipv6Addr {
618        Ipv6Addr::from_segments(rng.random::<[u16; 8]>())
619    }
620
621    fn rand_node() -> Node {
622        let mut rng = rand::rng();
623        let tailnet_address = TailnetAddress {
624            ipv4: rand_ipv4(&mut rng).into(),
625            ipv6: rand_ipv6(&mut rng).into(),
626        };
627
628        Node {
629            stable_id: StableNodeId(rand_string(&mut rng, 32)),
630            addresses: vec![tailnet_address.ipv4.into(), tailnet_address.ipv6.into()],
631            tailnet_address,
632            node_key: rng.random::<[u8; 32]>().into(),
633            key_signature: vec![],
634            disco_key: rng
635                .random::<bool>()
636                .then_some(rng.random::<[u8; 32]>().into()),
637            machine_key: rng
638                .random::<bool>()
639                .then_some(rng.random::<[u8; 32]>().into()),
640            id: rng.random(),
641            accepted_routes: (0..rng.random_range(0..32))
642                .map(|_| rand_route(&mut rng))
643                .collect(),
644
645            hostname: rand_string(&mut rng, 32),
646            user_id: rng.random(),
647            tailnet: rng.random::<bool>().then_some(rand_string(&mut rng, 32)),
648
649            node_key_expiry: None,
650            expired: false,
651            online: None,
652            last_seen: None,
653            underlay_addresses: vec![],
654            derp_region: rng
655                .random::<bool>()
656                .then_some(ts_derp::RegionId(rng.random())),
657
658            tags: (0..rng.random_range(0..8))
659                .map(|_| rand_string(&mut rng, 32))
660                .collect(),
661
662            cap: Default::default(),
663            cap_map: Default::default(),
664            peerapi_port: None,
665            peerapi_dns_proxy: false,
666            is_wireguard_only: false,
667            exit_node_dns_resolvers: vec![],
668            peer_relay: false,
669            ssh_host_keys: vec![],
670            service_vips: Default::default(),
671            unsigned_peer_api_only: false,
672        }
673    }
674
675    fn validate_indices(db: &PeerDb, node: &Node, id: PeerId) {
676        let ipv4 = IpAddr::from(node.tailnet_address.ipv4.addr());
677        let ipv6 = IpAddr::from(node.tailnet_address.ipv6.addr());
678        let fqdn = node.fqdn_opt(false);
679
680        let mut keys: Vec<&dyn IndexedField> =
681            vec![&id, &node.node_key, &node.stable_id, &node.id, &ipv4, &ipv6];
682
683        if let Some(disco) = &node.disco_key {
684            keys.push(disco);
685        }
686
687        if let Some(fqdn) = &fqdn {
688            keys.push(fqdn);
689        }
690
691        for k in keys {
692            let lookup_id = k.lookup(db).unwrap();
693            assert_eq!(lookup_id, id, "wrong id for key {k:?}");
694
695            let (lookup_id, lookup_node) = db.get(k).unwrap();
696            assert_eq!(lookup_id, id, "wrong id for key {k:?}");
697            assert_eq!(lookup_node, node, "wrong node for key {k:?}");
698        }
699
700        // We don't know if the hostname collides, but it should resolve to something
701        node.hostname.lookup(db).unwrap();
702
703        for &route in &node.accepted_routes {
704            // Generically we don't actually know if this node has the most specific match for this
705            // route, but there should at least be one match, and all matches should have at least
706            // one route that (inclusively) subsets our route.
707
708            let routes = db.get_route(route).collect::<Vec<_>>();
709            assert!(!routes.is_empty());
710
711            for (found_id, found_node) in routes {
712                if found_id == id {
713                    assert_eq!(found_node, node);
714                    break;
715                }
716
717                let has_subset = found_node
718                    .accepted_routes
719                    .iter()
720                    .any(|found_route| route.contains(found_route));
721
722                assert!(has_subset);
723            }
724        }
725    }
726
727    /// Assert that the node's routes are all present as the most specific routes in the
728    /// db.
729    fn assert_has_routes_exact(db: &PeerDb, node: &Node, id: PeerId) {
730        for &route in &node.accepted_routes {
731            let match_exists = db
732                .get_route(route)
733                .any(|(found_id, found_node)| found_id == id && found_node == node);
734
735            assert!(match_exists);
736        }
737    }
738
739    #[test]
740    fn test_indices() {
741        let mut db = PeerDb::default();
742        let node = rand_node();
743        let id = db.upsert(&node);
744
745        validate_indices(&db, &node, id);
746        assert_has_routes_exact(&db, &node, id);
747    }
748
749    #[test]
750    fn test_names() {
751        let mut db = PeerDb::default();
752
753        let node1 = Node {
754            hostname: "test".to_string(),
755            tailnet: Some("ts.net".to_string()),
756            ..rand_node()
757        };
758        let node2 = Node {
759            hostname: "test".to_string(),
760            tailnet: Some("ts2.net".to_string()),
761            ..rand_node()
762        };
763        let node3 = Node {
764            hostname: "test".to_string(),
765            tailnet: None,
766            ..rand_node()
767        };
768
769        let id1 = db.upsert(&node1);
770        let id2 = db.upsert(&node2);
771        let id3 = db.upsert(&node3);
772
773        let nodes = [(id1, &node1), (id2, &node2), (id3, &node3)];
774
775        for (id, node) in &nodes {
776            validate_indices(&db, node, *id);
777        }
778
779        let (id, node) = db.get(&"test").unwrap();
780        assert!(nodes.iter().any(|(x, _node)| *x == id));
781
782        for &(x, curnode) in &nodes {
783            if x == id {
784                assert_eq!(node, curnode);
785            } else {
786                assert_ne!(node, curnode);
787            }
788        }
789
790        let (id, node) = db.get(&"test.ts.net").unwrap();
791        assert_eq!(id, id1);
792        assert_eq!(node, &node1);
793
794        let (id, node) = db.get(&"test.ts2.net").unwrap();
795        assert_eq!(id, id2);
796        assert_eq!(node, &node2);
797    }
798
799    #[test]
800    fn test_name_lookup_is_canonicalized() {
801        // MagicDNS names are case-insensitive and may carry a trailing dot; lookups must match
802        // regardless of the caller's casing or trailing dot (tsnet `canonMapKey` parity).
803        let mut db = PeerDb::default();
804
805        let node = Node {
806            hostname: "MixedCase".to_string(),
807            tailnet: Some("Tail-Scale.ts.net".to_string()),
808            ..rand_node()
809        };
810        let id = db.upsert(&node);
811
812        // bare hostname: any case, no tailnet component
813        assert_eq!(db.get(&"mixedcase").unwrap().0, id);
814        assert_eq!(db.get(&"MIXEDCASE").unwrap().0, id);
815
816        // fqdn: any case, with and without trailing dot
817        assert_eq!(db.get(&"mixedcase.tail-scale.ts.net").unwrap().0, id);
818        assert_eq!(db.get(&"MixedCase.Tail-Scale.TS.NET").unwrap().0, id);
819        assert_eq!(db.get(&"mixedcase.tail-scale.ts.net.").unwrap().0, id);
820
821        // removal must also canonicalize, leaving no dangling index entries
822        db.remove(&id);
823        assert!(db.get(&"mixedcase").is_none());
824        assert!(db.get(&"mixedcase.tail-scale.ts.net").is_none());
825        assert!(db.index_state.is_empty());
826    }
827
828    #[test]
829    fn disco_key_reassigned_across_peers_no_panic() {
830        // Under netmap churn, control can transiently move a disco_key from one peer to another
831        // and then update the original peer. Before the fix, the old-value removal asserted the
832        // disco entry still mapped back to the original peer and panicked the actor (tsr-gxq).
833        let mut db = PeerDb::default();
834
835        let disco: DiscoPublicKey = [7u8; 32].into();
836
837        let node_a = Node {
838            disco_key: Some(disco),
839            ..rand_node()
840        };
841        let id_a = db.upsert(&node_a);
842
843        // B claims the same disco_key (churn / transient reuse). The disco index now points at B.
844        let node_b = Node {
845            disco_key: Some(disco),
846            ..rand_node()
847        };
848        let id_b = db.upsert(&node_b);
849        assert_ne!(id_a, id_b);
850
851        // Re-upsert A with no disco_key. The old-value removal must not panic even though the
852        // disco entry now belongs to B.
853        let node_a2 = Node {
854            disco_key: None,
855            ..node_a.clone()
856        };
857        let id_a2 = db.upsert(&node_a2);
858        assert_eq!(id_a, id_a2);
859
860        // The disco index should still resolve to the last writer (B), unharmed.
861        assert_eq!(disco.lookup(&db), Some(id_b));
862    }
863
864    /// Ingress resolves either of a peer's two known disco keys, and refuses everything else.
865    ///
866    /// This is the `PeerDb` half of Go `endpoint.checkAndUpdateDiscoKey`: a peer mid-rotation is
867    /// still sending under the key it has not switched away from, so that key has to attribute to
868    /// it — while a key nobody registered must not resolve to anything.
869    #[test]
870    fn either_known_disco_key_resolves_on_ingress() {
871        let mut db = PeerDb::default();
872
873        let active: DiscoPublicKey = [1u8; 32].into();
874        let inactive: DiscoPublicKey = [2u8; 32].into();
875        let stranger: DiscoPublicKey = [3u8; 32].into();
876
877        let node = Node {
878            disco_key: Some(active),
879            ..rand_node()
880        };
881        let id = db.upsert(&node);
882
883        assert!(
884            db.peer_by_known_disco_key(&inactive).is_none(),
885            "a peer with one key resolves only that key"
886        );
887
888        db.set_inactive_disco_key(id, Some(inactive));
889
890        let (got, _, matched) = db
891            .peer_by_known_disco_key(&active)
892            .expect("active resolves");
893        assert_eq!((got, matched), (id, DiscoKeyMatch::Active));
894
895        let (got, _, matched) = db
896            .peer_by_known_disco_key(&inactive)
897            .expect("the other known key resolves too");
898        assert_eq!((got, matched), (id, DiscoKeyMatch::Inactive));
899
900        assert!(
901            db.peer_by_known_disco_key(&stranger).is_none(),
902            "a key in neither slot is refused — the whole security value of the check"
903        );
904        assert_eq!(
905            inactive.lookup(&db),
906            None,
907            "and the send-side disco index still carries only the active key"
908        );
909
910        // Retracting it (the peer's two slots collapsed to one) removes it from ingress too.
911        db.set_inactive_disco_key(id, None);
912        assert!(db.peer_by_known_disco_key(&inactive).is_none());
913
914        // Removing the peer leaves no dangling entry in either index.
915        db.set_inactive_disco_key(id, Some(inactive));
916        db.remove(&id);
917        assert!(db.peer_by_known_disco_key(&inactive).is_none());
918        assert!(db.index_state.is_empty());
919    }
920
921    /// Two peers transiently claiming the same key, the ingress case of
922    /// `disco_key_reassigned_across_peers_no_panic`.
923    ///
924    /// A key that is one peer's ACTIVE key and another's stale inactive one resolves to the peer
925    /// actually using it — the active index is consulted first. Between two peers holding it
926    /// inactive, the answer is the last writer's, exactly like every other index here, and
927    /// retracting the loser's entry must not clobber the winner's.
928    #[test]
929    fn a_key_claimed_by_two_peers_resolves_to_the_one_using_it() {
930        let mut db = PeerDb::default();
931
932        let shared: DiscoPublicKey = [9u8; 32].into();
933
934        let node_a = Node {
935            disco_key: Some([1u8; 32].into()),
936            ..rand_node()
937        };
938        let id_a = db.upsert(&node_a);
939        db.set_inactive_disco_key(id_a, Some(shared));
940
941        // B holds it inactive too — the later writer wins the inactive index.
942        let node_b = Node {
943            disco_key: Some([2u8; 32].into()),
944            ..rand_node()
945        };
946        let id_b = db.upsert(&node_b);
947        db.set_inactive_disco_key(id_b, Some(shared));
948        assert_eq!(
949            db.peer_by_known_disco_key(&shared)
950                .map(|(id, _, m)| (id, m)),
951            Some((id_b, DiscoKeyMatch::Inactive))
952        );
953
954        // A retracts its claim. The guarded remove must leave B's mapping alone.
955        db.set_inactive_disco_key(id_a, None);
956        assert_eq!(
957            db.peer_by_known_disco_key(&shared)
958                .map(|(id, _, m)| (id, m)),
959            Some((id_b, DiscoKeyMatch::Inactive)),
960            "retracting another peer's stale claim must not clobber the live one"
961        );
962
963        // C is actively using it. An active claim outranks any inactive one.
964        let node_c = Node {
965            disco_key: Some(shared),
966            ..rand_node()
967        };
968        let id_c = db.upsert(&node_c);
969        assert_eq!(
970            db.peer_by_known_disco_key(&shared)
971                .map(|(id, _, m)| (id, m)),
972            Some((id_c, DiscoKeyMatch::Active)),
973            "the peer sending under the key beats one that merely still knows it"
974        );
975    }
976
977    #[test]
978    fn ip_reassigned_across_peers_no_panic() {
979        // Two peers transiently share a tailnet IP during churn, then the original changes IPs.
980        // Before the fix, the ip_idx old-value removal asserted ownership and panicked (tsr-gxq).
981        let mut db = PeerDb::default();
982
983        let shared = TailnetAddress {
984            ipv4: Ipv4Addr::new(100, 64, 0, 1).into(),
985            ipv6: Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 1).into(),
986        };
987
988        let node_a = Node {
989            addresses: vec![shared.ipv4.into(), shared.ipv6.into()],
990            tailnet_address: shared.clone(),
991            ..rand_node()
992        };
993        let id_a = db.upsert(&node_a);
994
995        // B claims the same tailnet IPs (churn). The ip index now points at B.
996        let node_b = Node {
997            addresses: vec![shared.ipv4.into(), shared.ipv6.into()],
998            tailnet_address: shared.clone(),
999            ..rand_node()
1000        };
1001        let id_b = db.upsert(&node_b);
1002        assert_ne!(id_a, id_b);
1003
1004        // Re-upsert A with different IPs. The old-value removal must not panic even though the
1005        // shared IP entries now belong to B.
1006        let renumbered = TailnetAddress {
1007            ipv4: Ipv4Addr::new(100, 64, 0, 2).into(),
1008            ipv6: Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 2).into(),
1009        };
1010        let node_a2 = Node {
1011            addresses: vec![renumbered.ipv4.into(), renumbered.ipv6.into()],
1012            tailnet_address: renumbered,
1013            ..node_a.clone()
1014        };
1015        let id_a2 = db.upsert(&node_a2);
1016        assert_eq!(id_a, id_a2);
1017
1018        // The shared IPs still resolve to the last writer (B), unharmed.
1019        assert_eq!(
1020            IpAddr::from(Ipv4Addr::new(100, 64, 0, 1)).lookup(&db),
1021            Some(id_b)
1022        );
1023        // A's new IP resolves to A.
1024        assert_eq!(
1025            IpAddr::from(Ipv4Addr::new(100, 64, 0, 2)).lookup(&db),
1026            Some(id_a)
1027        );
1028    }
1029
1030    /// The removal path's half of the guard: a peer that LEAVES must not evict the entries a
1031    /// successor already took over.
1032    ///
1033    /// Control can hand a churning peer's tailnet IP, MagicDNS name, disco key or control node id
1034    /// to a newer peer and deliver the newer peer's upsert first — which is what
1035    /// `PeerTracker::apply_peer_update` does with every delta, since it applies upserts before
1036    /// removals. An unconditional retraction in `IndexState::remove` then wipes the live owner's
1037    /// rows, and a peer that is present and handshaking stops resolving by IP (whois, peerAPI
1038    /// source checks) or by disco key until the next full netmap.
1039    #[test]
1040    fn a_departing_peer_does_not_evict_its_successors_index_rows() {
1041        let mut db = PeerDb::default();
1042
1043        let shared_ips = TailnetAddress {
1044            ipv4: Ipv4Addr::new(100, 64, 0, 7).into(),
1045            ipv6: Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 7).into(),
1046        };
1047        let shared_disco: DiscoPublicKey = [4u8; 32].into();
1048        let shared_node_key: NodePublicKey = [5u8; 32].into();
1049        let shared_control_id = 4242;
1050
1051        let departing = Node {
1052            stable_id: StableNodeId("departing".to_string()),
1053            id: shared_control_id,
1054            hostname: "churny".to_string(),
1055            tailnet: Some("ts.net".to_string()),
1056            addresses: vec![shared_ips.ipv4.into(), shared_ips.ipv6.into()],
1057            tailnet_address: shared_ips.clone(),
1058            node_key: shared_node_key,
1059            disco_key: Some(shared_disco),
1060            accepted_routes: Vec::new(),
1061            ..rand_node()
1062        };
1063        let departing_id = db.upsert(&departing);
1064
1065        // The successor inherits every one of those, and — the ordering that matters — is upserted
1066        // BEFORE the departing peer's removal arrives.
1067        let successor = Node {
1068            stable_id: StableNodeId("successor".to_string()),
1069            ..departing.clone()
1070        };
1071        let successor_id = db.upsert(&successor);
1072        assert_ne!(departing_id, successor_id);
1073
1074        let (removed_id, _node) = db
1075            .remove(&departing.stable_id)
1076            .expect("the departing peer is still in the db under its own stable id");
1077        assert_eq!(removed_id, departing_id);
1078
1079        assert_eq!(db.peers().len(), 1, "only the departing peer is gone");
1080        assert_eq!(
1081            departing.stable_id.lookup(&db),
1082            None,
1083            "and its own stable id no longer resolves"
1084        );
1085
1086        for (what, got) in [
1087            (
1088                "tailnet ipv4",
1089                IpAddr::from(Ipv4Addr::new(100, 64, 0, 7)).lookup(&db),
1090            ),
1091            (
1092                "tailnet ipv6",
1093                IpAddr::from(Ipv6Addr::new(0xfd7a, 0, 0, 0, 0, 0, 0, 7)).lookup(&db),
1094            ),
1095            ("hostname", "churny".lookup(&db)),
1096            ("fqdn", "churny.ts.net".lookup(&db)),
1097            ("disco key", shared_disco.lookup(&db)),
1098            ("node key", shared_node_key.lookup(&db)),
1099            ("control node id", shared_control_id.lookup(&db)),
1100            ("stable id", successor.stable_id.lookup(&db)),
1101        ] {
1102            assert_eq!(
1103                got,
1104                Some(successor_id),
1105                "the departing peer evicted the successor's {what}"
1106            );
1107        }
1108    }
1109
1110    /// The successor's claim can also arrive in an EARLIER batch than the removal (Go's
1111    /// `MapResponse` reordering); the removal still must not evict it. Same guard, the ordering
1112    /// that reaches the db across two updates rather than inside one.
1113    #[test]
1114    fn a_departing_peers_inactive_disco_claim_does_not_evict_the_successors() {
1115        let mut db = PeerDb::default();
1116
1117        let shared: DiscoPublicKey = [6u8; 32].into();
1118
1119        let departing = Node {
1120            stable_id: StableNodeId("departing".to_string()),
1121            disco_key: Some([1u8; 32].into()),
1122            accepted_routes: Vec::new(),
1123            ..rand_node()
1124        };
1125        let departing_id = db.upsert(&departing);
1126        db.set_inactive_disco_key(departing_id, Some(shared));
1127
1128        // The successor is now the one actually sending under that key.
1129        let successor = Node {
1130            stable_id: StableNodeId("successor".to_string()),
1131            disco_key: Some(shared),
1132            accepted_routes: Vec::new(),
1133            ..rand_node()
1134        };
1135        let successor_id = db.upsert(&successor);
1136
1137        db.remove(&departing_id).expect("departing peer removed");
1138
1139        assert_eq!(
1140            db.peer_by_known_disco_key(&shared)
1141                .map(|(id, _, m)| (id, m)),
1142            Some((successor_id, DiscoKeyMatch::Active)),
1143            "the departing peer's stale inactive claim must not take the live path with it"
1144        );
1145    }
1146
1147    /// The other direction, so the guard cannot pass by simply never evicting anything: a peer
1148    /// removed while it still OWNS its rows must leave the indexes empty.
1149    #[test]
1150    fn a_peer_removed_while_it_owns_its_rows_leaves_no_index_entries() {
1151        let mut db = PeerDb::default();
1152
1153        let node = Node {
1154            hostname: "solo".to_string(),
1155            tailnet: Some("ts.net".to_string()),
1156            disco_key: Some([3u8; 32].into()),
1157            accepted_routes: vec!["192.0.2.0/24".parse().unwrap()],
1158            ..rand_node()
1159        };
1160        let id = db.upsert(&node);
1161        db.set_inactive_disco_key(id, Some([4u8; 32].into()));
1162        validate_indices(&db, &node, id);
1163
1164        db.remove(&id).expect("the peer is in the db");
1165
1166        assert!(db.peers().is_empty());
1167        assert_eq!(node.node_key.lookup(&db), None);
1168        assert_eq!(node.stable_id.lookup(&db), None);
1169        assert_eq!(node.id.lookup(&db), None);
1170        assert_eq!(
1171            IpAddr::from(node.tailnet_address.ipv4.addr()).lookup(&db),
1172            None
1173        );
1174        assert_eq!(
1175            IpAddr::from(node.tailnet_address.ipv6.addr()).lookup(&db),
1176            None
1177        );
1178        assert_eq!("solo".lookup(&db), None);
1179        assert_eq!("solo.ts.net".lookup(&db), None);
1180        assert_eq!(node.disco_key.unwrap().lookup(&db), None);
1181        assert!(db.peer_by_known_disco_key(&[4u8; 32].into()).is_none());
1182        assert_eq!(db.get_route("192.0.2.0/24".parse().unwrap()).count(), 0);
1183        assert!(
1184            db.index_state.is_empty(),
1185            "an owned row must still be retracted — the guard is conditional, not a no-op"
1186        );
1187    }
1188
1189    #[test]
1190    fn node_key_or_stableid_churn_no_panic() {
1191        // Exercises the generic `maybe_update_idx` path (node_key / stable_id / control_idx). A
1192        // peer re-registering with a node_key that another peer transiently claimed must not panic
1193        // the actor on the old-value removal (tsr-gxq).
1194        let mut db = PeerDb::default();
1195
1196        let key: NodePublicKey = [9u8; 32].into();
1197
1198        let node_a = Node {
1199            node_key: key,
1200            ..rand_node()
1201        };
1202        let id_a = db.upsert(&node_a);
1203
1204        // B claims the same node_key (churn). The nk index now points at B.
1205        let node_b = Node {
1206            node_key: key,
1207            ..rand_node()
1208        };
1209        let id_b = db.upsert(&node_b);
1210        assert_ne!(id_a, id_b);
1211
1212        // Re-upsert A with a fresh node_key. The old-value removal must not panic even though the
1213        // old node_key entry now belongs to B.
1214        let node_a2 = Node {
1215            node_key: [10u8; 32].into(),
1216            ..node_a.clone()
1217        };
1218        let id_a2 = db.upsert(&node_a2);
1219        assert_eq!(id_a, id_a2);
1220
1221        // The churned node_key still resolves to B; A's fresh key resolves to A.
1222        assert_eq!(key.lookup(&db), Some(id_b));
1223        assert_eq!(NodePublicKey::from([10u8; 32]).lookup(&db), Some(id_a));
1224    }
1225
1226    proptest::prop_compose! {
1227        fn ipv4net()(
1228            addr: Ipv4Addr,
1229            pfx in 0u8..=32,
1230        ) -> ipnet::Ipv4Net {
1231            ipnet::Ipv4Net::new(addr, pfx).unwrap().trunc()
1232        }
1233    }
1234
1235    proptest::prop_compose! {
1236        fn ipv6net()(
1237            addr: Ipv6Addr,
1238            pfx in 0u8..=32,
1239        ) -> ipnet::Ipv6Net {
1240            ipnet::Ipv6Net::new(addr, pfx).unwrap().trunc()
1241        }
1242    }
1243
1244    fn ipnet() -> impl Strategy<Value = ipnet::IpNet> {
1245        proptest::prop_oneof![
1246            ipv4net().prop_map(ipnet::IpNet::from),
1247            ipv6net().prop_map(ipnet::IpNet::from)
1248        ]
1249    }
1250
1251    proptest::prop_compose! {
1252        // Lowercase only: names are canonicalized (case-insensitively) by `canon_name`, so the
1253        // `hash_set` uniqueness the node generators rely on must hold in canonical form too.
1254        // Mixed-case segments could collide after canonicalization and break index assertions.
1255        fn domain_segment()(
1256            seg in "[a-z][a-z0-9]*"
1257        ) -> String {
1258            seg
1259        }
1260    }
1261
1262    proptest::prop_compose! {
1263        fn domain(max_count: usize)(
1264            segs in proptest::collection::vec(domain_segment(), 0..max_count)
1265        ) -> String {
1266            segs.join(".")
1267        }
1268    }
1269
1270    type Key = [u8; 32];
1271
1272    proptest::prop_compose! {
1273        // This is set up this way to ensure uniqueness among all the required-unique keys in a
1274        // node. The `hash_set`s ensure that all ids AND stable ids AND node keys etc. are unique.
1275        fn nodes(n: usize)(
1276            id in hash_set(any::<i64>(), n),
1277            stable_id in hash_set(".+", n),
1278            tags in vec(hash_set(".+", 0..32), n),
1279            accepted_routes in vec(hash_set(ipnet(), 0..32), n),
1280            node_key in hash_set(any::<Key>(), n),
1281            machine_key in vec(any::<Option<Key>>(), n),
1282            disco_key in vec(any::<Option<Key>>(), n),
1283            ipv4 in hash_set(any::<Ipv4Addr>(), n),
1284            ipv6 in hash_set(any::<Ipv6Addr>(), n),
1285            name in hash_set(domain_segment(), n),
1286            tailnet in vec(domain(5), n),
1287            has_tailnet in vec(any::<bool>(), n),
1288            derp_region in vec(any::<Option<NonZeroU32>>(), n),
1289            underlay_addrs in vec(any::<HashSet<SocketAddr>>(), n),
1290        ) -> Vec<Node> {
1291            itertools::izip![
1292                id,
1293                stable_id,
1294                tags,
1295                accepted_routes,
1296                node_key,
1297                machine_key,
1298                disco_key,
1299                ipv4,
1300                ipv6,
1301                name,
1302                tailnet,
1303                has_tailnet,
1304                derp_region,
1305                underlay_addrs,
1306            ].map(|(
1307                id,
1308                stable_id,
1309                tags,
1310                mut accepted_routes,
1311                node_key,
1312                machine_key,
1313                disco_key,
1314                ipv4,
1315                ipv6,
1316                name,
1317                tailnet,
1318                has_tailnet,
1319                derp_region,
1320                underlay_addrs,
1321            )| {
1322                accepted_routes.insert(ipnet::Ipv4Net::from(ipv4).into());
1323                accepted_routes.insert(ipnet::Ipv6Net::from(ipv6).into());
1324
1325                Node {
1326                    id,
1327                    stable_id: StableNodeId(stable_id),
1328
1329                    hostname: name,
1330                    user_id: 0,
1331                    tailnet: has_tailnet.then_some(tailnet),
1332
1333                    node_key: node_key.into(),
1334                    key_signature: vec![],
1335                    disco_key: disco_key.map(Into::into),
1336                    machine_key: machine_key.map(Into::into),
1337
1338                    node_key_expiry: None,
1339                    expired: false,
1340            online: None,
1341            last_seen: None,
1342
1343                    addresses: vec![
1344                        ipnet::IpNet::V4(ipv4.into()),
1345                        ipnet::IpNet::V6(ipv6.into()),
1346                    ],
1347                    tailnet_address: TailnetAddress {
1348                        ipv4: ipv4.into(),
1349                        ipv6: ipv6.into(),
1350                    },
1351                    tags: tags.into_iter().collect(),
1352
1353                    derp_region: derp_region.map(ts_derp::RegionId),
1354
1355                    accepted_routes: accepted_routes.into_iter().collect(),
1356                    underlay_addresses: underlay_addrs.into_iter().collect(),
1357
1358                    cap: Default::default(),
1359                    cap_map: Default::default(),
1360                    peerapi_port: None,
1361                    peerapi_dns_proxy: false,
1362                    is_wireguard_only: false,
1363                    exit_node_dns_resolvers: vec![],
1364                    peer_relay: false,
1365                    ssh_host_keys: vec![],
1366                    service_vips: Default::default(),
1367                    unsigned_peer_api_only: false,
1368                }
1369            })
1370            .collect()
1371        }
1372    }
1373
1374    proptest::proptest! {
1375        #[test]
1376        fn prop_one_node_indices(mut nodes in nodes(1)) {
1377            let node = nodes.pop().unwrap();
1378
1379            let mut db = PeerDb::default();
1380            let id = db.upsert(&node);
1381
1382            validate_indices(&db, &node, id);
1383            assert_has_routes_exact(&db, &node, id);
1384        }
1385
1386        #[test]
1387        fn prop_many_nodes_indexed(nodes in nodes(16)) {
1388            let mut db = PeerDb::default();
1389
1390            let mut nodes_by_id = HashMap::new();
1391
1392            for node in &nodes {
1393                let id = db.upsert(node);
1394                nodes_by_id.insert(id, node.clone());
1395            }
1396
1397            for (id, node) in &nodes_by_id {
1398                validate_indices(&db, node, *id);
1399            }
1400        }
1401
1402        #[test]
1403        fn prop_remove(nodes in nodes(16)) {
1404            let mut db = PeerDb::default();
1405
1406            let mut ids = vec![];
1407
1408            for node in &nodes {
1409                ids.push((db.upsert(node), node));
1410            }
1411
1412            for (id, node) in ids {
1413                let (removed_id, removed_node) = db.remove(&id).unwrap();
1414
1415                proptest::prop_assert_eq!(removed_id, id);
1416                proptest::prop_assert_eq!(&removed_node, node);
1417            }
1418
1419            proptest::prop_assert!(db.peers.is_empty());
1420            proptest::prop_assert!(db.index_state.is_empty());
1421        }
1422    }
1423}