bp_core/network/
bootstrap.rs

1//! Bootstrap node list for initial WAN peer discovery.
2//!
3//! mDNS works only within a single LAN segment.  For nodes running on
4//! different networks, a list of well-known "bootstrap" peers with stable
5//! addresses is required to seed the Kademlia DHT and gossipsub mesh.
6//!
7//! ## File format
8//!
9//! `~/.local/share/billpouch/bootstrap.json` — a JSON array of multiaddr
10//! strings, each including a `/p2p/<PeerId>` suffix:
11//!
12//! ```json
13//! [
14//!   "/ip4/203.0.113.1/tcp/4001/p2p/12D3KooWExamplePeerIdAAAAA",
15//!   "/dns4/bootstrap.billpouch.io/tcp/4001/p2p/12D3KooWExampleBBBBB"
16//! ]
17//! ```
18//!
19//! The file is optional.  If it is absent or empty the daemon starts with
20//! mDNS-only discovery (suitable for purely local deployments).
21//!
22//! ## Runtime
23//!
24//! [`BootstrapList::apply`] is called once during [`run_network_loop`]
25//! initialisation.  For each entry it:
26//! 1. Parses the multiaddr.
27//! 2. Extracts the `/p2p/<PeerId>` component.
28//! 3. Registers the address in the Kademlia routing table.
29//! 4. Dials the peer.
30//!
31//! [`run_network_loop`]: crate::network::run_network_loop
32
33use libp2p::{multiaddr::Protocol, Multiaddr, PeerId, Swarm};
34use serde::{Deserialize, Serialize};
35use std::path::Path;
36
37use crate::network::behaviour::BillPouchBehaviour;
38
39// ── BootstrapList ──────────────────────────────────────────────────────────────
40
41/// A list of bootstrap node multiaddrs loaded from disk.
42#[derive(Debug, Default, Serialize, Deserialize)]
43pub struct BootstrapList(pub Vec<String>);
44
45impl BootstrapList {
46    /// Load from `path`.  Returns an empty list if the file is absent or
47    /// cannot be parsed (never fails hard).
48    pub fn load(path: &Path) -> Self {
49        std::fs::read_to_string(path)
50            .ok()
51            .and_then(|s| serde_json::from_str(&s).ok())
52            .unwrap_or_default()
53    }
54
55    /// Write the current list to `path` as a pretty JSON array.
56    pub fn save(&self, path: &Path) {
57        match serde_json::to_string_pretty(self) {
58            Ok(s) => {
59                if let Err(e) = std::fs::write(path, s) {
60                    tracing::warn!("bootstrap: failed to save list: {e}");
61                }
62            }
63            Err(e) => tracing::warn!("bootstrap: serialization error: {e}"),
64        }
65    }
66
67    /// Add an address string, deduplicating in place.
68    pub fn add(&mut self, addr: String) {
69        if !self.0.contains(&addr) {
70            self.0.push(addr);
71        }
72    }
73
74    /// Remove an address string if present.
75    pub fn remove(&mut self, addr: &str) {
76        self.0.retain(|a| a != addr);
77    }
78
79    /// Apply the list to `swarm`: parse each addr, add to Kademlia, and dial.
80    ///
81    /// Entries that cannot be parsed or that lack a `/p2p/<PeerId>` component
82    /// are skipped with a warning log.
83    pub fn apply(&self, swarm: &mut Swarm<BillPouchBehaviour>) {
84        if self.0.is_empty() {
85            return;
86        }
87        tracing::info!(n = self.0.len(), "bootstrap: applying bootstrap peer list");
88        for addr_str in &self.0 {
89            match addr_str.parse::<Multiaddr>() {
90                Ok(addr) => match peer_id_from_multiaddr(&addr) {
91                    Some(peer_id) => {
92                        swarm
93                            .behaviour_mut()
94                            .kad
95                            .add_address(&peer_id, addr.clone());
96                        swarm.behaviour_mut().gossipsub.add_explicit_peer(&peer_id);
97                        if let Err(e) = swarm.dial(addr.clone()) {
98                            tracing::warn!(addr = %addr_str, "bootstrap: dial failed: {e}");
99                        } else {
100                            tracing::debug!(addr = %addr_str, peer = %peer_id, "bootstrap: dialing");
101                        }
102                    }
103                    None => {
104                        tracing::warn!(
105                            addr = %addr_str,
106                            "bootstrap: no /p2p/<PeerId> in address, skipping"
107                        );
108                    }
109                },
110                Err(e) => {
111                    tracing::warn!(addr = %addr_str, "bootstrap: invalid multiaddr: {e}");
112                }
113            }
114        }
115    }
116}
117
118// ── Helpers ────────────────────────────────────────────────────────────────────
119
120/// Extract the [`PeerId`] from the `/p2p/<peer_id>` tail of a multiaddr, if
121/// present.
122fn peer_id_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
123    addr.iter().find_map(|proto| {
124        if let Protocol::P2p(peer_id) = proto {
125            Some(peer_id)
126        } else {
127            None
128        }
129    })
130}
131
132// ── Tests ─────────────────────────────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn tmp_path(name: &str) -> std::path::PathBuf {
139        std::env::temp_dir().join(format!("bp_bootstrap_test_{name}.json"))
140    }
141
142    #[test]
143    fn load_missing_returns_default() {
144        let list = BootstrapList::load(Path::new("/tmp/no_such_bootstrap_xyz.json"));
145        assert!(list.0.is_empty());
146    }
147
148    #[test]
149    fn load_corrupt_returns_default() {
150        let path = tmp_path("corrupt");
151        std::fs::write(&path, b"NOT JSON{{").unwrap();
152        let list = BootstrapList::load(&path);
153        let _ = std::fs::remove_file(&path);
154        assert!(list.0.is_empty());
155    }
156
157    #[test]
158    fn round_trip() {
159        let path = tmp_path("roundtrip");
160        let mut list = BootstrapList::default();
161        list.add("/ip4/1.2.3.4/tcp/4001/p2p/12D3KooWFakeAAA".into());
162        list.add("/ip4/5.6.7.8/tcp/4001/p2p/12D3KooWFakeBBB".into());
163        list.save(&path);
164
165        let loaded = BootstrapList::load(&path);
166        let _ = std::fs::remove_file(&path);
167        assert_eq!(loaded.0.len(), 2);
168    }
169
170    #[test]
171    fn add_deduplicates() {
172        let mut list = BootstrapList::default();
173        list.add("addr1".into());
174        list.add("addr1".into());
175        assert_eq!(list.0.len(), 1);
176    }
177
178    #[test]
179    fn remove_works() {
180        let mut list = BootstrapList::default();
181        list.add("addr1".into());
182        list.add("addr2".into());
183        list.remove("addr1");
184        assert_eq!(list.0, vec!["addr2".to_string()]);
185    }
186
187    #[test]
188    fn peer_id_from_multiaddr_with_p2p() {
189        // A real-looking (but fake) multiaddr with a valid PeerId base58 suffix.
190        // We just check that the function returns Some for a well-formed addr
191        // and None for one without /p2p/.
192        let addr_no_p2p: Multiaddr = "/ip4/1.2.3.4/tcp/4001".parse().unwrap();
193        assert!(peer_id_from_multiaddr(&addr_no_p2p).is_none());
194    }
195}