Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions crates/net/discv5/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ pub struct ConfigBuilder {
/// Custom filter rules to apply to a discovered peer in order to determine if it should be
/// passed up to rlpx or dropped.
discovered_peer_filter: Option<MustNotIncludeKeys>,
/// IP to advertise in the local [`Enr`](discv5::enr::Enr) regardless of the UDP listen
/// address. Intended for deployments where the node is reachable on an address it doesn't
/// bind locally (behind a load balancer, static NAT, or 1:1 DNAT).
advertised_ip: Option<IpAddr>,
}

impl ConfigBuilder {
Expand All @@ -100,6 +104,7 @@ impl ConfigBuilder {
bootstrap_lookup_interval,
bootstrap_lookup_countdown,
discovered_peer_filter,
advertised_ip,
} = discv5_config;

Self {
Expand All @@ -112,6 +117,7 @@ impl ConfigBuilder {
bootstrap_lookup_interval: Some(bootstrap_lookup_interval),
bootstrap_lookup_countdown: Some(bootstrap_lookup_countdown),
discovered_peer_filter: Some(discovered_peer_filter),
advertised_ip,
}
}

Expand Down Expand Up @@ -214,6 +220,20 @@ impl ConfigBuilder {
self
}

/// Sets the IP advertised in the local [`Enr`](discv5::enr::Enr).
///
/// Takes precedence over the IP derived from the UDP listen address when
/// [`build_local_enr`](crate::build_local_enr) constructs the ENR. Also disables discv5's
/// peer-observation ENR update so the static value doesn't get flapped by observed source
/// IPs.
///
/// Note: when paired with a dual-stack listen config and a v4-only override, the ENR will
/// carry a `udp6` port without an `ip6` address.
pub const fn advertised_ip(mut self, ip: IpAddr) -> Self {
self.advertised_ip = Some(ip);
self
}

/// Returns a new [`Config`].
pub fn build(self) -> Config {
let Self {
Expand All @@ -226,6 +246,7 @@ impl ConfigBuilder {
bootstrap_lookup_interval,
bootstrap_lookup_countdown,
discovered_peer_filter,
advertised_ip,
} = self;

let mut discv5_config = discv5_config.unwrap_or_else(|| {
Expand All @@ -235,6 +256,11 @@ impl ConfigBuilder {
discv5_config.listen_config =
amend_listen_config_wrt_rlpx(&discv5_config.listen_config, tcp_socket.ip());

// Peer-observation ENR updates must not overwrite a statically advertised address.
if advertised_ip.is_some() {
discv5_config.enr_update = false;
}

let fork = fork.map(|(key, fork_id)| (key, fork_id.into()));

let lookup_interval = lookup_interval.unwrap_or(DEFAULT_SECONDS_LOOKUP_INTERVAL);
Expand All @@ -256,6 +282,7 @@ impl ConfigBuilder {
bootstrap_lookup_interval,
bootstrap_lookup_countdown,
discovered_peer_filter,
advertised_ip,
}
}
}
Expand Down Expand Up @@ -289,6 +316,9 @@ pub struct Config {
/// Custom filter rules to apply to a discovered peer in order to determine if it should be
/// passed up to rlpx or dropped.
pub(super) discovered_peer_filter: MustNotIncludeKeys,
/// IP to advertise in the local [`Enr`](discv5::enr::Enr). Overrides the IP derived from the
/// UDP listen address when the ENR is built.
pub(super) advertised_ip: Option<IpAddr>,
}

impl Config {
Expand All @@ -305,6 +335,7 @@ impl Config {
bootstrap_lookup_interval: None,
bootstrap_lookup_countdown: None,
discovered_peer_filter: None,
advertised_ip: None,
}
}

Expand Down
122 changes: 121 additions & 1 deletion crates/net/discv5/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ pub fn build_local_enr(
) -> (Enr<SecretKey>, NodeRecord, Option<&'static [u8]>, IpMode) {
let mut builder = discv5::enr::Enr::builder();

let Config { discv5_config, fork, tcp_socket, other_enr_kv_pairs, .. } = config;
let Config { discv5_config, fork, tcp_socket, other_enr_kv_pairs, advertised_ip, .. } = config;

let socket = {
let v4 = crate::config::ipv4(&discv5_config.listen_config);
Expand Down Expand Up @@ -502,6 +502,19 @@ pub fn build_local_enr(
.unwrap_or_else(|| SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))
};

// Node may be reachable on an address it doesn't bind (e.g. behind a load balancer or static
// NAT); this wins unconditionally over the listen-derived IP.
if let Some(advertised) = advertised_ip {
match advertised.to_canonical() {
IpAddr::V4(ip) => {
builder.ip4(ip);
}
IpAddr::V6(ip) => {
builder.ip6(ip);
}
}
}

let rlpx_ip_mode = if tcp_socket.is_ipv4() { IpMode::Ip4 } else { IpMode::Ip6 };

// identifies which network node is on
Expand Down Expand Up @@ -1048,4 +1061,111 @@ mod test {
Err(Error::NetworkStackIdNotConfigured)
));
}

fn config_with_advertised_ip(
rlpx_addr: SocketAddr,
listen_config: ListenConfig,
advertised: IpAddr,
) -> Config {
Config::builder(rlpx_addr)
.discv5_config(discv5::ConfigBuilder::new(listen_config).build())
.advertised_ip(advertised)
.build()
}

#[test]
fn advertised_ipv4_set_in_enr() {
let sk = SecretKey::new(&mut thread_rng());
let advertised: Ipv4Addr = "1.2.3.4".parse().unwrap();
let config = config_with_advertised_ip(
"127.0.0.1:30303".parse().unwrap(),
ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port: 30304 },
advertised.into(),
);

let (enr, _, _, _) = build_local_enr(&sk, &config);

assert_eq!(enr.ip4(), Some(advertised));
}

#[test]
fn advertised_ipv6_set_in_enr() {
let sk = SecretKey::new(&mut thread_rng());
let advertised: Ipv6Addr = "2001:db8::1".parse().unwrap();
let config = config_with_advertised_ip(
"[::1]:30303".parse().unwrap(),
ListenConfig::Ipv6 { ip: Ipv6Addr::UNSPECIFIED, port: 30304 },
advertised.into(),
);

let (enr, _, _, _) = build_local_enr(&sk, &config);

assert_eq!(enr.ip6(), Some(advertised));
assert!(enr.ip4().is_none());
}

#[test]
fn advertised_ipv4_mapped_ipv6_is_canonicalized() {
let sk = SecretKey::new(&mut thread_rng());
let mapped: Ipv6Addr = "::ffff:1.2.3.4".parse().unwrap();
let config = config_with_advertised_ip(
"127.0.0.1:30303".parse().unwrap(),
ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port: 30304 },
mapped.into(),
);

let (enr, _, _, _) = build_local_enr(&sk, &config);

assert_eq!(enr.ip4(), Some("1.2.3.4".parse().unwrap()));
assert!(enr.ip6().is_none());
}

#[test]
fn advertised_ip_overrides_listen_ip() {
let sk = SecretKey::new(&mut thread_rng());
let advertised: Ipv4Addr = "1.2.3.4".parse().unwrap();
let config = config_with_advertised_ip(
"127.0.0.1:30303".parse().unwrap(),
ListenConfig::Ipv4 { ip: "10.0.0.1".parse().unwrap(), port: 30304 },
advertised.into(),
);

let (enr, _, _, _) = build_local_enr(&sk, &config);

assert_eq!(enr.ip4(), Some(advertised));
}

#[test]
fn advertised_ip_wins_on_dual_stack_listen() {
let sk = SecretKey::new(&mut thread_rng());
let advertised: Ipv4Addr = "1.2.3.4".parse().unwrap();
let config = config_with_advertised_ip(
"127.0.0.1:30303".parse().unwrap(),
ListenConfig::DualStack {
ipv4: Ipv4Addr::UNSPECIFIED,
ipv4_port: 30304,
ipv6: Ipv6Addr::UNSPECIFIED,
ipv6_port: 30304,
},
advertised.into(),
);

let (enr, _, _, _) = build_local_enr(&sk, &config);

assert_eq!(enr.ip4(), Some(advertised));
assert!(enr.ip6().is_none());
// udp6 remains (documented limitation: v4 override with dual-stack listen keeps udp6)
assert!(enr.udp6().is_some());
}

#[test]
fn advertised_ip_disables_enr_update() {
let config = config_with_advertised_ip(
"127.0.0.1:30303".parse().unwrap(),
ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port: 30304 },
Ipv4Addr::new(1, 2, 3, 4).into(),
);

assert!(!config.discv5_config.enr_update);
}
}
23 changes: 23 additions & 0 deletions crates/net/network/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,12 +657,21 @@ impl<N: NetworkPrimitives> NetworkConfigBuilder<N> {
total_difficulty: chain_spec.genesis().difficulty,
});

// Resolve the externally-advertised IP from the NAT resolver (if the variant is static:
// `extip:` or `extaddr:`). Async variants (`upnp`, `publicip`, `any`) return `None` here
// and continue to rely on discv5's peer-observation ENR update.
let advertised_enr_ip = nat.clone().and_then(|n| n.as_external_ip(0));

discovery_v5_builder = discovery_v5_builder.map(|mut builder| {
if let Some(network_stack_id) = NetworkStackId::id(&chain_spec) {
let fork_id = chain_spec.fork_id(&head);
builder = builder.fork(network_stack_id, fork_id)
}

if let Some(ip) = advertised_enr_ip {
builder = builder.advertised_ip(ip)
}

builder
});

Expand Down Expand Up @@ -862,4 +871,18 @@ mod tests {

assert_eq!(advertised_fork_id, fork_id);
}

#[test]
fn test_discv5_advertised_ip_from_nat() {
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();

let config = builder()
.external_ip_resolver(NatResolver::ExternalIp(ip.into()))
.discovery_v5(reth_discv5::Config::builder((Ipv4Addr::LOCALHOST, 30303).into()))
.build(NoopProvider::default());

let discv5_config = config.discovery_v5_config.expect("should have discv5 config");
let (enr, _, _, _) = build_local_enr(&config.secret_key, &discv5_config);
assert_eq!(enr.ip4(), Some(ip));
}
}
6 changes: 5 additions & 1 deletion crates/node/core/src/args/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,11 @@ pub struct NetworkArgs {
#[arg(long, verbatim_doc_comment)]
pub no_persist_peers: bool,

/// NAT resolution method (any|none|upnp|publicip|extip:\<IP\>)
/// NAT resolution method (any|none|upnp|publicip|extip:\<IP\>|extaddr:\<DOMAIN\>|netif)
///
/// Static variants (`extip:`, `extaddr:`) are also baked into the discv5 ENR's `ip4`/`ip6`
/// field at startup so peers gossip the advertised address; discv5's peer-observation ENR
/// update is disabled to keep the static value stable.
#[arg(long, default_value_t = DefaultNetworkArgs::get_global().nat.clone())]
pub nat: NatResolver,

Expand Down