Skip to main content

reth_cli_commands/p2p/
bootnode.rs

1//! Standalone bootnode command
2
3use clap::Parser;
4use reth_cli_util::{get_secret_key, load_secret_key::rng_secret_key};
5use reth_discv4::{DiscoveryUpdate, Discv4, Discv4Config};
6use reth_discv5::{
7    discv5::{self, Event, ListenConfig},
8    Config, Discv5,
9};
10use reth_net_nat::NatResolver;
11use reth_network_peers::NodeRecord;
12use secp256k1::SecretKey;
13use std::{
14    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
15    path::PathBuf,
16    sync::Arc,
17};
18use tokio::{net::UdpSocket, select};
19use tokio_stream::StreamExt;
20use tracing::info;
21
22/// Start a discovery only bootnode.
23#[derive(Parser, Debug)]
24pub struct Command {
25    /// Listen address for the bootnode (default: "0.0.0.0:30301").
26    #[arg(long, default_value = "0.0.0.0:30301")]
27    pub addr: SocketAddr,
28
29    /// Secret key to use for the bootnode.
30    ///
31    /// This will also deterministically set the peer ID.
32    /// If a path is provided but no key exists at that path,
33    /// a new random secret will be generated and stored there.
34    /// If no path is specified, a new ephemeral random secret will be used.
35    #[arg(long, value_name = "PATH")]
36    pub p2p_secret_key: Option<PathBuf>,
37
38    /// NAT resolution method (any|none|upnp|publicip|extip:\<IP\>).
39    ///
40    /// Can be repeated with one IPv4 and one IPv6 `extip:<IP>` to advertise a dual-stack discv5
41    /// ENR (discv4 binds a single socket and always advertises only the `--addr` family).
42    #[arg(long, default_value = "any")]
43    pub nat: Vec<NatResolver>,
44
45    /// Also run discv5, sharing the discv4 UDP port (`--addr`).
46    #[arg(long)]
47    pub v5: bool,
48}
49
50impl Command {
51    /// Execute the bootnode command.
52    pub async fn execute(self) -> eyre::Result<()> {
53        info!("Bootnode started with config: {self:?}");
54
55        let sk = self.network_secret()?;
56        // A discovery-only bootnode serves no RLPx, so advertise TCP port 0
57        // (`enode://…@<ip>:0?discport=<udp>`).
58        let local_enr = NodeRecord::from_secret_key(self.addr, &sk).with_tcp_port(0);
59        let nat = self.resolved_nat()?;
60
61        let discv4_config = self.discv4_config(&nat);
62
63        // In v5 mode discv4 and discv5 share a single UDP socket: discv5 owns the read loop and
64        // discv4 packets surface as `UnrecognizedFrame`s forwarded to its ingress. The discv5
65        // service must be kept alive for the event loop lifetime.
66        let (_discv4, mut discv4_service, _discv5, mut discv5_forwarder) = if self.v5 {
67            let shared_socket = bind_socket(self.addr).await?;
68            // Actual port, so an ephemeral `--addr` port (`:0`) still yields one shared port.
69            let shared_port = shared_socket.local_addr()?.port();
70
71            let (discv4, discv4_service, mut ingress) =
72                Discv4::bind_shared(shared_socket.clone(), local_enr, sk, discv4_config)?;
73            info!("Started discv4 (shared port) at address: {local_enr:?}");
74
75            // Hand discv5 the shared socket and bind the opposite family (if advertised) on the
76            // same port.
77            let mut discv5_config = self.discv5_config(&nat);
78            let discv5_cfg = discv5_config.discv5_config_mut();
79            let (mut ipv4, mut ipv6) = (None, None);
80            if self.addr.is_ipv4() {
81                ipv4 = Some(shared_socket);
82                if let Some(mut addr) = reth_discv5::config::ipv6(&discv5_cfg.listen_config) {
83                    addr.set_port(shared_port);
84                    ipv6 = Some(bind_socket(SocketAddr::V6(addr)).await?);
85                }
86            } else {
87                ipv6 = Some(shared_socket);
88                if let Some(mut addr) = reth_discv5::config::ipv4(&discv5_cfg.listen_config) {
89                    addr.set_port(shared_port);
90                    ipv4 = Some(bind_socket(SocketAddr::V4(addr)).await?);
91                }
92            }
93            discv5_cfg.listen_config = ListenConfig::FromSockets { ipv4, ipv6 };
94
95            info!("Starting discv5 (shared port)");
96            let (discv5, mut updates) = Discv5::start(&sk, discv5_config).await?;
97            log_discv5_enr(&discv5);
98
99            // A dedicated task forwards discv4 frames to their ingress and logs discv5 sessions.
100            let forwarder = tokio::spawn(async move {
101                while let Some(event) = updates.recv().await {
102                    match event {
103                        Event::UnrecognizedFrame(frame) => {
104                            ingress.handle_packet(&frame.packet, frame.src_address).await
105                        }
106                        Event::SessionEstablished(enr, _) => {
107                            info!("(Discv5) new peer added, peer_id={:?}", enr.id())
108                        }
109                        _ => {}
110                    }
111                }
112                info!("(Discv5) update stream ended.");
113            });
114
115            (discv4, discv4_service, Some(discv5), Some(forwarder))
116        } else {
117            let (discv4, discv4_service) =
118                Discv4::bind(self.addr, local_enr, sk, discv4_config).await?;
119            info!("Started discv4 at address: {local_enr:?}");
120            (discv4, discv4_service, None, None)
121        };
122
123        let mut discv4_updates = discv4_service.update_stream();
124        discv4_service.spawn();
125
126        // event info loop for logging
127        loop {
128            select! {
129                update = discv4_updates.next() => {
130                    if let Some(update) = update {
131                        match update {
132                            DiscoveryUpdate::Added(record) => {
133                                info!("(Discv4) new peer added, peer_id={:?}", record.id);
134                            }
135                            DiscoveryUpdate::Removed(peer_id) => {
136                                info!("(Discv4) peer with peer-id={:?} removed", peer_id);
137                            }
138                            _ => {}
139                        }
140                    } else {
141                        info!("(Discv4) update stream ended.");
142                        break;
143                    }
144                }
145                // discv5 logging lives in the forwarder task; end the loop when it exits.
146                _ = async {
147                    if let Some(forwarder) = &mut discv5_forwarder {
148                        let _ = forwarder.await;
149                    } else {
150                        futures::future::pending::<()>().await
151                    }
152                } => break,
153            }
154        }
155
156        Ok(())
157    }
158
159    fn network_secret(&self) -> eyre::Result<SecretKey> {
160        match &self.p2p_secret_key {
161            Some(path) => Ok(get_secret_key(path)?),
162            None => Ok(rng_secret_key()),
163        }
164    }
165
166    /// Resolves the configured `--nat` values into the addresses to advertise.
167    ///
168    /// A single value behaves like the rest of the node: the resolver drives discv4's external IP
169    /// resolution, and any fixed IP is also advertised in the discv5 ENR. Two `extip:<IP>` values
170    /// (one per family) advertise a dual-stack discv5 record; discv4 advertises only the family
171    /// matching `--addr`, since it binds a single socket.
172    fn resolved_nat(&self) -> eyre::Result<BootnodeNat> {
173        match self.nat.as_slice() {
174            [] => Ok(BootnodeNat::single(NatResolver::Any, self.addr.port())),
175            [nat] => {
176                let mut single = BootnodeNat::single(nat.clone(), self.addr.port());
177                // A static IP (`extip`/`extaddr`) of the opposite family to `--addr` can't drive
178                // discv4 (it binds only the `--addr` family): advertise it via discv5 only, and
179                // use `None` rather than `Any` so discv4 doesn't silently auto-resolve an address
180                // the operator never provided.
181                if let [ip] = single.advertised_ips[..] &&
182                    ip.is_ipv4() != self.addr.is_ipv4()
183                {
184                    single.resolver = NatResolver::None;
185                }
186                Ok(single)
187            }
188            [first, second] => {
189                let first_ip = fixed_external_ip(first)?;
190                let second_ip = fixed_external_ip(second)?;
191
192                if first_ip.is_ipv4() == second_ip.is_ipv4() {
193                    eyre::bail!("repeated --nat requires one IPv4 and one IPv6 extip:<IP> value");
194                }
195
196                // discv4 binds a single socket, so its resolver must use the `--addr` family.
197                let primary_ip =
198                    if first_ip.is_ipv4() == self.addr.is_ipv4() { first_ip } else { second_ip };
199
200                Ok(BootnodeNat {
201                    resolver: NatResolver::ExternalIp(primary_ip),
202                    advertised_ips: vec![first_ip, second_ip],
203                })
204            }
205            _ => {
206                eyre::bail!("--nat can be provided at most twice");
207            }
208        }
209    }
210
211    fn discv4_config(&self, nat: &BootnodeNat) -> Discv4Config {
212        Discv4Config::builder().external_ip_resolver(Some(nat.resolver.clone())).build()
213    }
214
215    fn discv5_config(&self, nat: &BootnodeNat) -> Config {
216        // Discovery-only bootnode: no RLPx, so the ENR carries `tcp=0`; the discovery listen
217        // port is set from `--addr` below.
218        let mut builder = Config::builder(SocketAddr::new(self.addr.ip(), 0));
219
220        // discv5 shares discv4's UDP port.
221        let port = self.addr.port();
222
223        // Bind every family we advertise (plus the `--addr` family). Without a listen socket of a
224        // given family, `build_local_enr` drops that family's advertised IP even though setting it
225        // disables `enr_update` — which would leave discv5 with no usable record.
226        let bind_ipv4 = self.addr.is_ipv4() || nat.advertised_ips.iter().any(IpAddr::is_ipv4);
227        let bind_ipv6 = self.addr.is_ipv6() || nat.advertised_ips.iter().any(IpAddr::is_ipv6);
228        let listen = if bind_ipv4 && bind_ipv6 {
229            ListenConfig::DualStack {
230                ipv4: Ipv4Addr::UNSPECIFIED,
231                ipv4_port: port,
232                ipv6: Ipv6Addr::UNSPECIFIED,
233                ipv6_port: port,
234            }
235        } else if bind_ipv6 {
236            ListenConfig::Ipv6 { ip: Ipv6Addr::UNSPECIFIED, port }
237        } else {
238            ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port }
239        };
240        builder = builder.discv5_config(discv5::ConfigBuilder::new(listen).build());
241
242        for ip in &nat.advertised_ips {
243            builder = builder.advertised_ip(*ip);
244        }
245
246        builder.build()
247    }
248}
249
250/// Resolved `--nat` configuration for the bootnode.
251#[derive(Debug)]
252struct BootnodeNat {
253    /// Resolver driving discv4's external IP resolution.
254    resolver: NatResolver,
255    /// Fixed IPs to advertise in the discv5 ENR (one per family).
256    advertised_ips: Vec<IpAddr>,
257}
258
259impl BootnodeNat {
260    fn single(resolver: NatResolver, port: u16) -> Self {
261        let advertised_ips = resolver.clone().as_external_ip(port).into_iter().collect();
262        Self { resolver, advertised_ips }
263    }
264}
265
266/// Binds a UDP socket for shared discv4/discv5 use.
267///
268/// IPv6 sockets are bound with `IPV6_V6ONLY=true` so an IPv4 sibling socket on the same port
269/// doesn't clash, matching how discv5 binds its `DualStack` sockets.
270async fn bind_socket(addr: SocketAddr) -> eyre::Result<Arc<UdpSocket>> {
271    let socket = match addr {
272        SocketAddr::V4(_) => UdpSocket::bind(addr).await?,
273        SocketAddr::V6(_) => {
274            use socket2::{Domain, Protocol, Socket, Type};
275            let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
276            socket.set_only_v6(true)?;
277            socket.set_nonblocking(true)?;
278            socket.bind(&addr.into())?;
279            UdpSocket::from_std(socket.into())?
280        }
281    };
282    Ok(Arc::new(socket))
283}
284
285fn fixed_external_ip(nat: &NatResolver) -> eyre::Result<IpAddr> {
286    match nat {
287        NatResolver::ExternalIp(ip) => Ok(*ip),
288        _ => {
289            eyre::bail!("--nat can only be repeated with extip:<IP> values");
290        }
291    }
292}
293
294fn log_discv5_enr(discv5: &Discv5) {
295    let enr = discv5.local_enr();
296    info!(
297        id = ?enr.node_id(),
298        ip4 = ?enr.ip4(),
299        ip6 = ?enr.ip6(),
300        udp4 = ?enr.udp4(),
301        udp6 = ?enr.udp6(),
302        tcp4 = ?enr.tcp4(),
303        tcp6 = ?enr.tcp6(),
304        "(Discv5) advertised endpoints",
305    );
306    info!("(Discv5) enr: {enr}");
307    if let Some(record) = discv5.node_record() {
308        info!("(Discv5) enode: {record}");
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use reth_discv5::build_local_enr;
316
317    #[test]
318    fn repeated_nat_uses_matching_addr_family_as_primary() {
319        let command = Command::parse_from([
320            "reth",
321            "--addr",
322            "[::]:30303",
323            "--nat",
324            "extip:1.2.3.4",
325            "--nat",
326            "extip:2001:db8::1",
327        ]);
328
329        let nat = command.resolved_nat().unwrap();
330
331        // discv4 binds the `--addr` (IPv6) family, so its resolver uses the IPv6 extip; both
332        // families are still advertised by discv5.
333        assert_eq!(nat.resolver, NatResolver::ExternalIp("2001:db8::1".parse().unwrap()));
334        assert_eq!(nat.advertised_ips.len(), 2);
335    }
336
337    #[test]
338    fn repeated_nat_requires_extip_values() {
339        let command = Command::parse_from(["reth", "--nat", "any", "--nat", "extip:2001:db8::1"]);
340
341        assert!(command.resolved_nat().is_err());
342    }
343
344    #[test]
345    fn repeated_nat_requires_distinct_ip_families() {
346        let command =
347            Command::parse_from(["reth", "--nat", "extip:1.2.3.4", "--nat", "extip:5.6.7.8"]);
348
349        assert!(command.resolved_nat().is_err());
350    }
351
352    #[test]
353    fn single_extip_is_advertised() {
354        let command =
355            Command::parse_from(["reth", "--addr", "0.0.0.0:30301", "--nat", "extip:1.2.3.4"]);
356
357        let nat = command.resolved_nat().unwrap();
358
359        assert_eq!(nat.advertised_ips, vec!["1.2.3.4".parse::<IpAddr>().unwrap()]);
360    }
361
362    #[test]
363    fn discv5_advertises_single_extip_of_other_family_than_addr() {
364        // A v6 extip under a v4 `--addr` must still be bound and advertised, not silently dropped.
365        let command = Command::parse_from([
366            "reth",
367            "--addr",
368            "0.0.0.0:45678",
369            "--v5",
370            "--nat",
371            "extip:2001:db8::1",
372        ]);
373        let nat = command.resolved_nat().unwrap();
374        let config = command.discv5_config(&nat);
375        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
376
377        let (enr, _, _, _) = build_local_enr(&sk, &config);
378
379        // discv5 shares discv4's port, so it advertises the `--addr` port.
380        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
381        assert_eq!(enr.udp6(), Some(45678));
382    }
383
384    #[test]
385    fn discv5_config_single_stack_ipv4_uses_addr_port() {
386        // A single-family (v4-only) bootnode: discv5 listens/advertises the `--addr` port, v4 only.
387        let command = Command::parse_from([
388            "reth",
389            "--addr",
390            "0.0.0.0:45678",
391            "--v5",
392            "--nat",
393            "extip:1.2.3.4",
394        ]);
395        let nat = command.resolved_nat().unwrap();
396        let config = command.discv5_config(&nat);
397        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
398
399        let (enr, _, _, _) = build_local_enr(&sk, &config);
400
401        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
402        assert_eq!(enr.udp4(), Some(45678));
403        assert_eq!(enr.udp6(), None);
404    }
405
406    #[test]
407    fn discv5_config_single_stack_ipv6_uses_addr_port() {
408        // A single-family (v6-only) bootnode: discv5 listens/advertises the `--addr` port, v6 only.
409        let command = Command::parse_from([
410            "reth",
411            "--addr",
412            "[::]:45678",
413            "--v5",
414            "--nat",
415            "extip:2001:db8::1",
416        ]);
417        let nat = command.resolved_nat().unwrap();
418        let config = command.discv5_config(&nat);
419        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
420
421        let (enr, _, _, _) = build_local_enr(&sk, &config);
422
423        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
424        assert_eq!(enr.udp6(), Some(45678));
425        assert_eq!(enr.udp4(), None);
426    }
427
428    #[test]
429    fn single_opposite_family_extip_does_not_drive_discv4() {
430        // A single v6 extip under a v4 `--addr` must not become discv4's external IP (discv4 binds
431        // only the v4 shared socket); it is still advertised via discv5.
432        let command = Command::parse_from([
433            "reth",
434            "--addr",
435            "0.0.0.0:45678",
436            "--v5",
437            "--nat",
438            "extip:2001:db8::1",
439        ]);
440        let nat = command.resolved_nat().unwrap();
441
442        assert_eq!(nat.resolver, NatResolver::None);
443        assert_eq!(nat.advertised_ips, vec!["2001:db8::1".parse::<IpAddr>().unwrap()]);
444    }
445
446    #[tokio::test]
447    async fn shared_socket_binds_both_families_on_one_port() {
448        // A v4 and a v6 socket must bind the SAME port (V6ONLY prevents the clash). The probes
449        // skip single-stack hosts; past them, a regressed V6ONLY must fail loudly.
450        let (Ok(probe4), Ok(probe6)) = (
451            bind_socket("0.0.0.0:0".parse().unwrap()).await,
452            bind_socket("[::]:0".parse().unwrap()).await,
453        ) else {
454            return;
455        };
456        drop((probe4, probe6));
457
458        let v4 = bind_socket("0.0.0.0:0".parse().unwrap()).await.unwrap();
459        let port = v4.local_addr().unwrap().port();
460        let v6 = bind_socket(format!("[::]:{port}").parse().unwrap()).await.unwrap();
461        assert_eq!(v6.local_addr().unwrap().port(), port);
462    }
463
464    #[test]
465    fn discv4_config_advertises_only_the_addr_family() {
466        // discv4 binds a single socket, so it must not advertise the secondary (IPv6) family it
467        // cannot serve; the IPv4 resolver drives discv4 and only discv5 carries both families.
468        let command = Command::parse_from([
469            "reth",
470            "--addr",
471            "0.0.0.0:30303",
472            "--nat",
473            "extip:1.2.3.4",
474            "--nat",
475            "extip:2001:db8::1",
476        ]);
477        let nat = command.resolved_nat().unwrap();
478        let config = command.discv4_config(&nat);
479
480        assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap()));
481        assert!(config.additional_eip868_rlp_pairs.is_empty());
482    }
483
484    #[test]
485    fn discv5_config_advertises_dual_stack_nat_endpoints() {
486        let command = Command::parse_from([
487            "reth",
488            "--addr",
489            "0.0.0.0:45678",
490            "--v5",
491            "--nat",
492            "extip:1.2.3.4",
493            "--nat",
494            "extip:2001:db8::1",
495        ]);
496        let nat = command.resolved_nat().unwrap();
497        let config = command.discv5_config(&nat);
498        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
499
500        let (enr, _, _, _) = build_local_enr(&sk, &config);
501
502        // discv5 shares discv4's port, so both families advertise the `--addr` port.
503        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
504        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
505        assert_eq!(enr.udp4(), Some(45678));
506        assert_eq!(enr.udp6(), Some(45678));
507        // No RLPx: tcp=0.
508        assert_eq!(enr.tcp4(), Some(0));
509        assert_eq!(enr.tcp6(), None);
510
511        // The derived enode renders as `…@<ip>:0?discport=<udp>`.
512        let record = NodeRecord::try_from(&enr).unwrap();
513        assert_eq!(record.tcp_port, 0);
514        assert_eq!(record.udp_port, 45678);
515    }
516}