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            _ => eyre::bail!("--nat can be provided at most twice"),
206        }
207    }
208
209    fn discv4_config(&self, nat: &BootnodeNat) -> Discv4Config {
210        Discv4Config::builder().external_ip_resolver(Some(nat.resolver.clone())).build()
211    }
212
213    fn discv5_config(&self, nat: &BootnodeNat) -> Config {
214        // Discovery-only bootnode: no RLPx, so the ENR carries `tcp=0`; the discovery listen
215        // port is set from `--addr` below.
216        let mut builder = Config::builder(SocketAddr::new(self.addr.ip(), 0));
217
218        // discv5 shares discv4's UDP port.
219        let port = self.addr.port();
220
221        // Bind every family we advertise (plus the `--addr` family). Without a listen socket of a
222        // given family, `build_local_enr` drops that family's advertised IP even though setting it
223        // disables `enr_update` — which would leave discv5 with no usable record.
224        let bind_ipv4 = self.addr.is_ipv4() || nat.advertised_ips.iter().any(IpAddr::is_ipv4);
225        let bind_ipv6 = self.addr.is_ipv6() || nat.advertised_ips.iter().any(IpAddr::is_ipv6);
226        let listen = if bind_ipv4 && bind_ipv6 {
227            ListenConfig::DualStack {
228                ipv4: Ipv4Addr::UNSPECIFIED,
229                ipv4_port: port,
230                ipv6: Ipv6Addr::UNSPECIFIED,
231                ipv6_port: port,
232            }
233        } else if bind_ipv6 {
234            ListenConfig::Ipv6 { ip: Ipv6Addr::UNSPECIFIED, port }
235        } else {
236            ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port }
237        };
238        builder = builder.discv5_config(discv5::ConfigBuilder::new(listen).build());
239
240        for ip in &nat.advertised_ips {
241            builder = builder.advertised_ip(*ip);
242        }
243
244        builder.build()
245    }
246}
247
248/// Resolved `--nat` configuration for the bootnode.
249#[derive(Debug)]
250struct BootnodeNat {
251    /// Resolver driving discv4's external IP resolution.
252    resolver: NatResolver,
253    /// Fixed IPs to advertise in the discv5 ENR (one per family).
254    advertised_ips: Vec<IpAddr>,
255}
256
257impl BootnodeNat {
258    fn single(resolver: NatResolver, port: u16) -> Self {
259        let advertised_ips = resolver.clone().as_external_ip(port).into_iter().collect();
260        Self { resolver, advertised_ips }
261    }
262}
263
264/// Binds a UDP socket for shared discv4/discv5 use.
265///
266/// IPv6 sockets are bound with `IPV6_V6ONLY=true` so an IPv4 sibling socket on the same port
267/// doesn't clash, matching how discv5 binds its `DualStack` sockets.
268async fn bind_socket(addr: SocketAddr) -> eyre::Result<Arc<UdpSocket>> {
269    let socket = match addr {
270        SocketAddr::V4(_) => UdpSocket::bind(addr).await?,
271        SocketAddr::V6(_) => {
272            use socket2::{Domain, Protocol, Socket, Type};
273            let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
274            socket.set_only_v6(true)?;
275            socket.set_nonblocking(true)?;
276            socket.bind(&addr.into())?;
277            UdpSocket::from_std(socket.into())?
278        }
279    };
280    Ok(Arc::new(socket))
281}
282
283fn fixed_external_ip(nat: &NatResolver) -> eyre::Result<IpAddr> {
284    match nat {
285        NatResolver::ExternalIp(ip) => Ok(*ip),
286        _ => eyre::bail!("--nat can only be repeated with extip:<IP> values"),
287    }
288}
289
290fn log_discv5_enr(discv5: &Discv5) {
291    let enr = discv5.local_enr();
292    info!(
293        id = ?enr.node_id(),
294        ip4 = ?enr.ip4(),
295        ip6 = ?enr.ip6(),
296        udp4 = ?enr.udp4(),
297        udp6 = ?enr.udp6(),
298        tcp4 = ?enr.tcp4(),
299        tcp6 = ?enr.tcp6(),
300        "(Discv5) advertised endpoints",
301    );
302    info!("(Discv5) enr: {enr}");
303    if let Some(record) = discv5.node_record() {
304        info!("(Discv5) enode: {record}");
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use reth_discv5::build_local_enr;
312
313    #[test]
314    fn repeated_nat_uses_matching_addr_family_as_primary() {
315        let command = Command::parse_from([
316            "reth",
317            "--addr",
318            "[::]:30303",
319            "--nat",
320            "extip:1.2.3.4",
321            "--nat",
322            "extip:2001:db8::1",
323        ]);
324
325        let nat = command.resolved_nat().unwrap();
326
327        // discv4 binds the `--addr` (IPv6) family, so its resolver uses the IPv6 extip; both
328        // families are still advertised by discv5.
329        assert_eq!(nat.resolver, NatResolver::ExternalIp("2001:db8::1".parse().unwrap()));
330        assert_eq!(nat.advertised_ips.len(), 2);
331    }
332
333    #[test]
334    fn repeated_nat_requires_extip_values() {
335        let command = Command::parse_from(["reth", "--nat", "any", "--nat", "extip:2001:db8::1"]);
336
337        assert!(command.resolved_nat().is_err());
338    }
339
340    #[test]
341    fn repeated_nat_requires_distinct_ip_families() {
342        let command =
343            Command::parse_from(["reth", "--nat", "extip:1.2.3.4", "--nat", "extip:5.6.7.8"]);
344
345        assert!(command.resolved_nat().is_err());
346    }
347
348    #[test]
349    fn single_extip_is_advertised() {
350        let command =
351            Command::parse_from(["reth", "--addr", "0.0.0.0:30301", "--nat", "extip:1.2.3.4"]);
352
353        let nat = command.resolved_nat().unwrap();
354
355        assert_eq!(nat.advertised_ips, vec!["1.2.3.4".parse::<IpAddr>().unwrap()]);
356    }
357
358    #[test]
359    fn discv5_advertises_single_extip_of_other_family_than_addr() {
360        // A v6 extip under a v4 `--addr` must still be bound and advertised, not silently dropped.
361        let command = Command::parse_from([
362            "reth",
363            "--addr",
364            "0.0.0.0:45678",
365            "--v5",
366            "--nat",
367            "extip:2001:db8::1",
368        ]);
369        let nat = command.resolved_nat().unwrap();
370        let config = command.discv5_config(&nat);
371        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
372
373        let (enr, _, _, _) = build_local_enr(&sk, &config);
374
375        // discv5 shares discv4's port, so it advertises the `--addr` port.
376        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
377        assert_eq!(enr.udp6(), Some(45678));
378    }
379
380    #[test]
381    fn discv5_config_single_stack_ipv4_uses_addr_port() {
382        // A single-family (v4-only) bootnode: discv5 listens/advertises the `--addr` port, v4 only.
383        let command = Command::parse_from([
384            "reth",
385            "--addr",
386            "0.0.0.0:45678",
387            "--v5",
388            "--nat",
389            "extip:1.2.3.4",
390        ]);
391        let nat = command.resolved_nat().unwrap();
392        let config = command.discv5_config(&nat);
393        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
394
395        let (enr, _, _, _) = build_local_enr(&sk, &config);
396
397        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
398        assert_eq!(enr.udp4(), Some(45678));
399        assert_eq!(enr.udp6(), None);
400    }
401
402    #[test]
403    fn discv5_config_single_stack_ipv6_uses_addr_port() {
404        // A single-family (v6-only) bootnode: discv5 listens/advertises the `--addr` port, v6 only.
405        let command = Command::parse_from([
406            "reth",
407            "--addr",
408            "[::]:45678",
409            "--v5",
410            "--nat",
411            "extip:2001:db8::1",
412        ]);
413        let nat = command.resolved_nat().unwrap();
414        let config = command.discv5_config(&nat);
415        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
416
417        let (enr, _, _, _) = build_local_enr(&sk, &config);
418
419        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
420        assert_eq!(enr.udp6(), Some(45678));
421        assert_eq!(enr.udp4(), None);
422    }
423
424    #[test]
425    fn single_opposite_family_extip_does_not_drive_discv4() {
426        // A single v6 extip under a v4 `--addr` must not become discv4's external IP (discv4 binds
427        // only the v4 shared socket); it is still advertised via discv5.
428        let command = Command::parse_from([
429            "reth",
430            "--addr",
431            "0.0.0.0:45678",
432            "--v5",
433            "--nat",
434            "extip:2001:db8::1",
435        ]);
436        let nat = command.resolved_nat().unwrap();
437
438        assert_eq!(nat.resolver, NatResolver::None);
439        assert_eq!(nat.advertised_ips, vec!["2001:db8::1".parse::<IpAddr>().unwrap()]);
440    }
441
442    #[tokio::test]
443    async fn shared_socket_binds_both_families_on_one_port() {
444        // A v4 and a v6 socket must bind the SAME port (V6ONLY prevents the clash). The probes
445        // skip single-stack hosts; past them, a regressed V6ONLY must fail loudly.
446        let (Ok(probe4), Ok(probe6)) = (
447            bind_socket("0.0.0.0:0".parse().unwrap()).await,
448            bind_socket("[::]:0".parse().unwrap()).await,
449        ) else {
450            return;
451        };
452        drop((probe4, probe6));
453
454        let v4 = bind_socket("0.0.0.0:0".parse().unwrap()).await.unwrap();
455        let port = v4.local_addr().unwrap().port();
456        let v6 = bind_socket(format!("[::]:{port}").parse().unwrap()).await.unwrap();
457        assert_eq!(v6.local_addr().unwrap().port(), port);
458    }
459
460    #[test]
461    fn discv4_config_advertises_only_the_addr_family() {
462        // discv4 binds a single socket, so it must not advertise the secondary (IPv6) family it
463        // cannot serve; the IPv4 resolver drives discv4 and only discv5 carries both families.
464        let command = Command::parse_from([
465            "reth",
466            "--addr",
467            "0.0.0.0:30303",
468            "--nat",
469            "extip:1.2.3.4",
470            "--nat",
471            "extip:2001:db8::1",
472        ]);
473        let nat = command.resolved_nat().unwrap();
474        let config = command.discv4_config(&nat);
475
476        assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap()));
477        assert!(config.additional_eip868_rlp_pairs.is_empty());
478    }
479
480    #[test]
481    fn discv5_config_advertises_dual_stack_nat_endpoints() {
482        let command = Command::parse_from([
483            "reth",
484            "--addr",
485            "0.0.0.0:45678",
486            "--v5",
487            "--nat",
488            "extip:1.2.3.4",
489            "--nat",
490            "extip:2001:db8::1",
491        ]);
492        let nat = command.resolved_nat().unwrap();
493        let config = command.discv5_config(&nat);
494        let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap();
495
496        let (enr, _, _, _) = build_local_enr(&sk, &config);
497
498        // discv5 shares discv4's port, so both families advertise the `--addr` port.
499        assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
500        assert_eq!(enr.ip6(), Some("2001:db8::1".parse().unwrap()));
501        assert_eq!(enr.udp4(), Some(45678));
502        assert_eq!(enr.udp6(), Some(45678));
503        // No RLPx: tcp=0.
504        assert_eq!(enr.tcp4(), Some(0));
505        assert_eq!(enr.tcp6(), None);
506
507        // The derived enode renders as `…@<ip>:0?discport=<udp>`.
508        let record = NodeRecord::try_from(&enr).unwrap();
509        assert_eq!(record.tcp_port, 0);
510        assert_eq!(record.udp_port, 45678);
511    }
512}