diff --git a/Cargo.lock b/Cargo.lock index 2f2aa860e9..f9c23501c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6421,6 +6421,7 @@ dependencies = [ "parity-scale-codec", "rpc-description", "serde", + "serde_json", "serialization", ] diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 404bd9d8d2..309295c6f2 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -13,9 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::net::{Ipv4Addr, Ipv6Addr}; - -use crate::types::peer_address::PeerAddress; +use crate::types::{global_ip::IsGlobalIp, peer_address::PeerAddress}; // IPv4 addresses grouped into /16 subnets pub const IPV4_GROUP_BYTES: usize = 2; @@ -39,20 +37,30 @@ impl AddressGroup { /// /// See `NetGroupManager::GetGroup` in Bitcoin Core for a reference. pub fn from_peer_address(address: &PeerAddress) -> AddressGroup { - if address.is_global_unicast_ip() { - match address { - PeerAddress::Ip4(addr) => AddressGroup::PublicV4( - Ipv4Addr::from(addr.ip).octets()[0..IPV4_GROUP_BYTES] + // PeerAddress is not canonicalized at construction, so the reduction to the + // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run + // on the canonical form. Note that other IPv4-embedded encodings (6to4, Teredo, + // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6: 6to4 + // keeps per-/16 granularity of the embedded IPv4 (the key holds the 6to4 prefix + // plus its first two octets), while all Teredo (2001:0::/32), all NAT64 + // (64:ff9b::/96), and all IPv4-translated (::ffff:0:0:0/96) addresses collapse + // into one group bucket each, unlike the Teredo-aware grouping of Bitcoin + // Core's GetGroup. + let std_ip = address.canonical_ip(); + if std_ip.is_global_unicast_ip() { + match std_ip { + std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( + ip.octets()[0..IPV4_GROUP_BYTES] .try_into() - .expect("must be valid"), + .expect("group size must not exceed the IPv4 octet count"), ), - PeerAddress::Ip6(addr) => AddressGroup::PublicV6( - Ipv6Addr::from(addr.ip).octets()[0..IPV6_GROUP_BYTES] + std::net::IpAddr::V6(ip) => AddressGroup::PublicV6( + ip.octets()[0..IPV6_GROUP_BYTES] .try_into() - .expect("must be valid"), + .expect("group size must not exceed the IPv6 octet count"), ), } - } else if address.is_loopback() { + } else if std_ip.is_loopback() { AddressGroup::Local } else { AddressGroup::Private @@ -91,3 +99,42 @@ mod tests { ); } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + + #[test] + fn mapped_addresses_are_grouped_like_the_embedded_ipv4() { + use crate::peer_manager::tests::utils::mapped_v6_peer_address; + + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::LOCALHOST, + 12345 + )), + AddressGroup::Local + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(192, 168, 1, 1), + 12345 + )), + AddressGroup::Private + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(10, 0, 0, 1), + 12345 + )), + AddressGroup::Private + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(1, 2, 3, 4), + 12345 + )), + AddressGroup::PublicV4([1, 2]) + ); + } +} diff --git a/p2p/src/peer_manager/mod.rs b/p2p/src/peer_manager/mod.rs index 4b12c69905..e6363fc621 100644 --- a/p2p/src/peer_manager/mod.rs +++ b/p2p/src/peer_manager/mod.rs @@ -1445,6 +1445,9 @@ where } let socket_addr = addr.socket_addr(); + // SocketAddress is canonical by construction, so a mapped form + // (::ffff:a.b.c.d) counts towards the same per-ip connection limit as the + // plain ipv4 form. cur_conn_ip_port_to_role_map.insert((socket_addr.ip(), socket_addr.port()), role); } @@ -2078,8 +2081,9 @@ where ) -> bool { assert!(new_peer_role.is_outbound()); - let new_peer_ip = new_peer_addr.socket_addr().ip(); - let new_peer_port = new_peer_addr.socket_addr().port(); + let new_peer_addr_socket = new_peer_addr.socket_addr(); + let new_peer_ip = new_peer_addr_socket.ip(); + let new_peer_port = new_peer_addr_socket.port(); existing_connections.range((new_peer_ip, 0)..=(new_peer_ip, u16::MAX)).all( |((_, existing_peer_port), existing_peer_role)| { self.may_allow_outbound_connection_to_existing_ip( diff --git a/p2p/src/peer_manager/peerdb/storage.rs b/p2p/src/peer_manager/peerdb/storage.rs index d91af220c0..20f6df260e 100644 --- a/p2p/src/peer_manager/peerdb/storage.rs +++ b/p2p/src/peer_manager/peerdb/storage.rs @@ -71,6 +71,15 @@ pub trait PeerDbStorageWrite { fn add_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; + + /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so + /// that they are keyed by the canonical form; without this, removals would miss the + /// legacy rows and bans could resurrect after a restart. + /// + /// Returns whether any rows were rewritten. A `false` result guarantees no writes were + /// staged on this transaction, so callers may skip committing it; a `true` result means + /// the transaction must be committed for the rewrites to take effect. + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result; } // Note: here we want to say something like: diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 05bf141e95..7b4587a77a 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -124,6 +124,172 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()> { Ok(self.storage().get_mut::().del(address.to_string())?) } + + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result { + fn canonical_spelling( + table: &'static str, + key: &str, + prefix: &str, + ) -> LegacyKeyAction + where + ::Err: std::fmt::Display, + { + // Legacy mapped spellings always start with the prefix, so the parse can + // be skipped for the vast majority of keys on every startup. Socket + // addresses were persisted through their Display form, which brackets + // IPv6 addresses. + if !key.starts_with(prefix) { + return LegacyKeyAction::Keep; + } + let address = match key.parse::() { + Ok(address) => address, + Err(err) => { + logging::log::warn!( + "Dropping unparseable legacy {table} key {key}: {err}; the row \ + cannot be read by this version" + ); + return LegacyKeyAction::Drop; + } + }; + let canonical = address.to_string(); + if canonical == key { + LegacyKeyAction::Keep + } else { + LegacyKeyAction::Rewrite(canonical) + } + } + + const MAPPED_SOCKET_ADDRESS_PREFIX: &str = "[::ffff:"; + const MAPPED_BANNABLE_ADDRESS_PREFIX: &str = "::ffff:"; + + let storage = self.storage(); + let mut rewrote_any = false; + + rewrote_any |= rekey_mapped_rows( + &mut storage.get_mut::(), + |key| { + canonical_spelling::( + "known addresses", + key, + MAPPED_SOCKET_ADDRESS_PREFIX, + ) + }, + // A legacy row marked as tried keeps its selection preference. + |canonical, legacy| match legacy { + KnownAddressState::Tried => legacy, + KnownAddressState::New => canonical, + }, + )?; + rewrote_any |= rekey_mapped_rows( + &mut storage.get_mut::(), + |key| { + canonical_spelling::( + "anchor addresses", + key, + MAPPED_SOCKET_ADDRESS_PREFIX, + ) + }, + |_canonical, _legacy| (), + )?; + rewrote_any |= rekey_mapped_rows( + &mut storage.get_mut::(), + |key| { + canonical_spelling::( + "banned addresses", + key, + MAPPED_BANNABLE_ADDRESS_PREFIX, + ) + }, + // The later unban time wins so the migration cannot shorten a ban. + std::cmp::max, + )?; + rewrote_any |= rekey_mapped_rows( + &mut storage.get_mut::(), + |key| { + canonical_spelling::( + "discouraged addresses", + key, + MAPPED_BANNABLE_ADDRESS_PREFIX, + ) + }, + std::cmp::max, + )?; + + Ok(rewrote_any) + } +} + +/// What to do with a key during the legacy fold. +enum LegacyKeyAction { + // Not a legacy mapped-form key; leave the row alone. + Keep, + // A legacy mapped-form key that no longer parses; drop the row. It cannot + // be loaded by the regular read path either, so keeping it would fail the + // startup load on every run. + Drop, + // Rewrite the row under the canonical spelling of the same address. + Rewrite(String), +} + +/// Rewrite rows keyed by a non-canonical spelling of an IPv4 address (the mapped form +/// `::ffff:a.b.c.d` written by an older version) so that the key matches the canonical +/// spelling used at runtime, merging the value with an existing canonical row via +/// `merge` when one is already present. Rows whose key no longer parses are dropped. +fn rekey_mapped_rows( + map: &mut storage::MapMut<'_, storage::TransactionRw<'_, B, Schema>, DBM>, + key_action: impl Fn(&str) -> LegacyKeyAction, + merge: impl Fn(DBM::Value, DBM::Value) -> DBM::Value, +) -> crate::Result +where + B: storage::SharedBackend, + DBM: storage::schema::DbMap, + DBM::Value: serialization::Encode + serialization::EncodeLike, +{ + let mut rewrites = Vec::new(); + let mut drops = Vec::new(); + for (key, encoded) in map.prefix_iter(&())? { + match key_action(&key) { + LegacyKeyAction::Keep => {} + LegacyKeyAction::Drop => drops.push(key), + LegacyKeyAction::Rewrite(canonical) => { + // Decode explicitly so a corrupt row is reported instead of panicking. + let value = + ::decode_all(&mut encoded.as_ref()) + .map_err(|err| { + P2pError::InvalidStorageState(format!( + "Failed to decode the value of the row keyed by {key}: {err}" + )) + })?; + rewrites.push((key, canonical, value)); + } + } + } + let mut rewrote_any = false; + for legacy_key in drops { + map.del(&legacy_key)?; + rewrote_any = true; + } + for (legacy_key, canonical_key, legacy_value) in rewrites { + map.del(&legacy_key)?; + let value = match map.get(&canonical_key)? { + Some(existing) => { + // Decode explicitly so a corrupt canonical row is reported too. + let existing_value = ::decode_all( + &mut existing.as_ref(), + ) + .map_err(|err| { + P2pError::InvalidStorageState(format!( + "Failed to decode the value of the row keyed by {canonical_key}: {err}" + )) + })?; + merge(existing_value, legacy_value) + } + None => legacy_value, + }; + map.put(&canonical_key, value)?; + rewrote_any = true; + } + Ok(rewrote_any) } impl PeerDbStorageRead for PeerDbStoreTxRo<'_, B> { diff --git a/p2p/src/peer_manager/peerdb/storage_load.rs b/p2p/src/peer_manager/peerdb/storage_load.rs index adefdfe1d8..5b696f9db5 100644 --- a/p2p/src/peer_manager/peerdb/storage_load.rs +++ b/p2p/src/peer_manager/peerdb/storage_load.rs @@ -83,6 +83,19 @@ impl LoadedStorage { } fn load_storage_v3(storage: &S) -> crate::Result { + { + // Rewrite any rows written by an older version under the mapped spelling of an + // IPv4 address, so the stored keys match the canonical form used at runtime. + let mut write_tx = storage.transaction_rw()?; + // Commit only when rows were actually rewritten, so the common case of an + // already-canonical database stays read-only. + if write_tx.normalize_legacy_mapped_address_keys()? { + write_tx.commit()?; + } + // TODO: once older databases are no longer expected, replace this + // every-startup scan with a one-time versioned migration or a stored marker. + } + let tx = storage.transaction_ro()?; let known_addresses = tx.get_known_addresses()?.into_iter().collect::>(); diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 1b3c88385c..f8b06b6187 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1088,3 +1088,306 @@ fn new_addr_table(peerdb: &PeerDb) -> &Table { fn tried_addr_table(peerdb: &PeerDb) -> &Table { peerdb.address_tables.tried_addr_table() } + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + tx.commit().unwrap(); + } + + // A store whose keys are already canonical needs no rewrites, so the caller can + // skip committing the migration transaction. + { + let mut tx = db_store.transaction_rw().unwrap(); + assert!(!tx.normalize_legacy_mapped_address_keys().unwrap()); + } + + { + let mut tx = db_store.transaction_rw().unwrap(); + let mut known = tx.storage().get_mut::(); + known.put("[::ffff:1.2.3.4]:12345".to_owned(), KnownAddressState::New).unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)) + .unwrap(); + tx.commit().unwrap(); + } + + { + let mut tx = db_store.transaction_rw().unwrap(); + assert!(tx.normalize_legacy_mapped_address_keys().unwrap()); + tx.commit().unwrap(); + } + + // Pin the other half of the contract: committing a `true` result persists the rewrite. + { + use storage::MakeMapRef as _; + + let tx = db_store.transaction_ro().unwrap(); + let known = tx.storage().get::(); + assert!(known.get("[::ffff:1.2.3.4]:12345").unwrap().is_none()); + assert!(known.get("1.2.3.4:12345").unwrap().is_some()); + let banned = tx.storage().get::(); + assert!(banned.get("::ffff:1.2.3.4").unwrap().is_none()); + assert!(banned.get("1.2.3.4").unwrap().is_some()); + } +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; + use crate::peer_manager::peerdb::storage_load::LoadedStorage; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; + use storage::MakeMapRef as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + // Seed the store with rows keyed by the mapped spelling, as an older version could + // have written them before the canonicalization was introduced. + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + tx.storage() + .get_mut::() + .put("[::ffff:1.2.3.4]:12345".to_owned(), KnownAddressState::New) + .unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)) + .unwrap(); + // A legacy mapped key that no longer parses cannot be loaded by the regular + // read path either; the fold must drop it instead of failing every startup. + tx.storage() + .get_mut::() + .put( + "[::ffff:not-an-address]:12345".to_owned(), + KnownAddressState::New, + ) + .unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:not-an-ip".to_owned(), Duration::from_secs(60)) + .unwrap(); + tx.commit().unwrap(); + } + + let peerdb_config = test_p2p_config().peer_manager_config.peerdb_config; + LoadedStorage::load_storage(&db_store, &peerdb_config, &mut rng).unwrap(); + + let tx = db_store.transaction_ro().unwrap(); + assert!( + tx.storage() + .get::() + .get("[::ffff:1.2.3.4]:12345") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("[::ffff:not-an-address]:12345") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("::ffff:not-an-ip") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("1.2.3.4:12345") + .unwrap() + .is_some() + ); + assert!( + tx.storage() + .get::() + .get("::ffff:1.2.3.4") + .unwrap() + .is_none() + ); + assert!(tx.storage().get::().get("1.2.3.4").unwrap().is_some()); + + // Sanity check on the canonical spellings, mirroring what the runtime writes. + let canonical_socket: SocketAddress = "1.2.3.4:12345".parse().unwrap(); + assert_eq!(canonical_socket.to_string(), "1.2.3.4:12345"); + let canonical_bannable: BannableAddress = "1.2.3.4".parse().unwrap(); + assert_eq!(canonical_bannable.to_string(), "1.2.3.4"); +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::{ + DBAnchorAddresses, DBBannedAddresses, DBDiscouragedAddresses, DBKnownAddresses, + }; + use crate::peer_manager::peerdb::storage_load::LoadedStorage; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + use storage::MakeMapRef as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + let mut known = tx.storage().get_mut::(); + known + .put( + "[::ffff:1.2.3.4]:12345".to_owned(), + KnownAddressState::Tried, + ) + .unwrap(); + known.put("1.2.3.4:12345".to_owned(), KnownAddressState::New).unwrap(); + known.put("[::ffff:9.9.9.9]:9999".to_owned(), KnownAddressState::New).unwrap(); + known.put("9.9.9.9:9999".to_owned(), KnownAddressState::Tried).unwrap(); + + let mut banned = tx.storage().get_mut::(); + banned.put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)).unwrap(); + banned.put("1.2.3.4".to_owned(), Duration::from_secs(120)).unwrap(); + banned.put("::ffff:5.6.7.8".to_owned(), Duration::from_secs(300)).unwrap(); + banned.put("5.6.7.8".to_owned(), Duration::from_secs(120)).unwrap(); + + tx.storage() + .get_mut::() + .put("::ffff:7.7.7.7".to_owned(), Duration::from_secs(30)) + .unwrap(); + tx.storage() + .get_mut::() + .put("7.7.7.7".to_owned(), Duration::from_secs(90)) + .unwrap(); + tx.storage() + .get_mut::() + .put("[::ffff:8.8.8.8]:8888".to_owned(), ()) + .unwrap(); + tx.commit().unwrap(); + } + + let peerdb_config = test_p2p_config().peer_manager_config.peerdb_config; + LoadedStorage::load_storage(&db_store, &peerdb_config, &mut rng).unwrap(); + + let tx = db_store.transaction_ro().unwrap(); + // The legacy Tried state merges over the canonical New state. + assert_eq!( + tx.storage() + .get::() + .get("1.2.3.4:12345") + .unwrap() + .unwrap() + .decode(), + KnownAddressState::Tried + ); + // The reverse pair: a legacy New row must not downgrade a canonical Tried row. + assert_eq!( + tx.storage() + .get::() + .get("9.9.9.9:9999") + .unwrap() + .unwrap() + .decode(), + KnownAddressState::Tried + ); + assert!( + tx.storage() + .get::() + .get("[::ffff:1.2.3.4]:12345") + .unwrap() + .is_none() + ); + + // The same merge rule applies to ban rows: the later unban time wins, regardless of + // whether it comes from the legacy or the canonical row. + assert_eq!( + tx.storage() + .get::() + .get("1.2.3.4") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(120) + ); + assert_eq!( + tx.storage() + .get::() + .get("5.6.7.8") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(300) + ); + assert!( + tx.storage() + .get::() + .get("::ffff:5.6.7.8") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("::ffff:1.2.3.4") + .unwrap() + .is_none() + ); + + // Discouraged rows follow the same later-time-wins rule, and anchor rows keep + // their unit value when folded. + assert_eq!( + tx.storage() + .get::() + .get("7.7.7.7") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(90) + ); + assert!( + tx.storage() + .get::() + .get("::ffff:7.7.7.7") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("8.8.8.8:8888") + .unwrap() + .is_some() + ); + assert!( + tx.storage() + .get::() + .get("[::ffff:8.8.8.8]:8888") + .unwrap() + .is_none() + ); +} diff --git a/p2p/src/peer_manager/tests/addresses.rs b/p2p/src/peer_manager/tests/addresses.rs index fa737d987a..3e750bff7f 100644 --- a/p2p/src/peer_manager/tests/addresses.rs +++ b/p2p/src/peer_manager/tests/addresses.rs @@ -852,3 +852,74 @@ async fn accept_outbound_connection( peer_id } + +// An inbound peer announcing an IPv4-mapped IPv6 form of a non-routable address must be +// rejected exactly like the plain IPv4 form, and a mapped routable address must be stored +// in its IPv4 form. +#[tracing::instrument(skip(seed))] +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn announce_mapped_ipv4_address_follows_ipv4_rules(#[case] seed: Seed) { + type TestNetworkingService = DefaultNetworkingService; + + let mut rng = make_seedable_rng(seed); + + let bind_address = TestTransportChannel::make_address(); + let config = Arc::new(config::create_unit_test_config()); + let p2p_config = Arc::new(test_p2p_config()); + let time_getter = BasicTestTimeGetter::new(); + let (mut pm, _peer_mgr_event_sender, _shutdown_sender, _subscribers_sender) = + make_peer_manager_custom::( + TestTransportChannel::make_transport(), + bind_address.into(), + Arc::clone(&config), + p2p_config, + time_getter.get_time_getter(), + make_seedable_rng(rng.random()), + ) + .await; + + let peer_address = TestAddressMaker::new_random_address(&mut rng); + let peer_id = PeerId::new(); + let peer_info = PeerInfo { + peer_id, + protocol_version: TEST_PROTOCOL_VERSION, + network: *config.magic_bytes(), + software_version: *config.software_version(), + user_agent: mintlayer_core_user_agent(), + common_services: NodeType::Full.into(), + }; + pm.accept_connection( + peer_address.into(), + bind_address.into(), + ConnectionDirection::Inbound, + peer_info, + None, + ); + assert_eq!(pm.peers.len(), 1); + + let mapped = crate::peer_manager::tests::utils::mapped_v6_peer_address; + + // Non-routable embedded IPv4 addresses are rejected like their plain form; nothing + // must enter the known address table. + for ip in [ + std::net::Ipv4Addr::LOCALHOST, + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(192, 168, 1, 1), + std::net::Ipv4Addr::new(169, 254, 1, 1), + ] { + pm.handle_announce_addr_request(peer_id, mapped(ip, 12345)); + } + assert_eq!(pm.peerdb.known_addresses_count(), 0); + + // A mapped routable address is accepted and stored in its IPv4 form. + pm.handle_announce_addr_request(peer_id, mapped(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345)); + assert_eq!(pm.peerdb.known_addresses_count(), 1); + let known = pm.peerdb.known_addresses().next().copied().unwrap(); + assert_eq!( + known.socket_addr(), + "8.8.8.8:12345".parse::().unwrap() + ); +} diff --git a/p2p/src/peer_manager/tests/utils.rs b/p2p/src/peer_manager/tests/utils.rs index 12d1e89a2e..d641d04180 100644 --- a/p2p/src/peer_manager/tests/utils.rs +++ b/p2p/src/peer_manager/tests/utils.rs @@ -21,7 +21,11 @@ use common::{chain::ChainConfig, primitives::user_agent::mintlayer_core_user_age use logging::log; use p2p_test_utils::wait_for_recv; use p2p_types::{ - PeerId, bannable_address::BannableAddress, services::Service, socket_address::SocketAddress, + PeerId, + bannable_address::BannableAddress, + peer_address::{PeerAddress, PeerAddressIp6}, + services::Service, + socket_address::SocketAddress, }; use test_utils::{BasicTestTimeGetter, assert_matches_return_val}; use utils_networking::IpOrSocketAddress; @@ -346,3 +350,22 @@ pub async fn add_reserved_peer( result_receiver.await.unwrap().unwrap(); } + +// Build an IPv4-mapped IPv6 peer address (::ffff:a.b.c.d). +pub(crate) fn mapped_v6_peer_address(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + let o = ip.octets(); + PeerAddress::Ip6(PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port, + }) +} diff --git a/p2p/types/Cargo.toml b/p2p/types/Cargo.toml index 40601766b7..585a7a5a9d 100644 --- a/p2p/types/Cargo.toml +++ b/p2p/types/Cargo.toml @@ -16,3 +16,4 @@ parity-scale-codec.workspace = true serde.workspace = true [dev-dependencies] +serde_json.workspace = true diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index adc230922a..5f991e2e7b 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -19,8 +19,6 @@ use std::{ str::FromStr, }; -use serde::{Deserialize, Serialize}; - #[derive( Debug, Copy, @@ -29,15 +27,33 @@ use serde::{Deserialize, Serialize}; Eq, PartialOrd, Ord, - Serialize, - Deserialize, + serde::Serialize, rpc_description::HasValueHint, )] +// Note: the derived newtype `Serialize` delegates to the inner `IpAddr`, matching the +// manual `Deserialize` below; keep the two impls wire-compatible if this changes. pub struct BannableAddress(IpAddr); +// Deserialize routes through `new` so that persisted bans stored in the mapped form are +// treated as their plain IPv4 form when loaded. Serialization round-trips are therefore +// normalizing, not identity-preserving: a value serialized as `"::ffff:a.b.c.d"` reads +// back as `a.b.c.d`. This is intended (one address, one ban key), but any serialized +// consumer of this type sees the canonical form after a round-trip. +impl<'de> serde::Deserialize<'de> for BannableAddress { + fn deserialize>(deserializer: D) -> Result { + ::deserialize(deserializer).map(BannableAddress::new) + } +} + impl BannableAddress { pub fn new(ip: IpAddr) -> Self { - Self(ip) + // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that a mapped + // address and the plain IPv4 address it represents share one ban key. + Self(crate::socket_address::canonical_ip_addr(ip)) + } + + pub fn ip_addr(&self) -> IpAddr { + self.0 } } @@ -51,6 +67,22 @@ impl FromStr for BannableAddress { type Err = AddrParseError; fn from_str(s: &str) -> Result { - IpAddr::from_str(s).map(BannableAddress) + IpAddr::from_str(s).map(BannableAddress::new) + } +} + +#[cfg(test)] +mod bannable_address_tests { + use super::*; + + #[test] + fn mapped_addresses_share_the_ban_key_with_the_embedded_ipv4() { + let plain = IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4)); + let mapped: IpAddr = "::ffff:1.2.3.4".parse().unwrap(); + assert_eq!(BannableAddress::new(mapped), BannableAddress::new(plain)); + + // Deserializing the mapped spelling directly must fold it to the plain form. + let deserialized: BannableAddress = serde_json::from_str("\"::ffff:1.2.3.4\"").unwrap(); + assert_eq!(deserialized, BannableAddress::new(plain)); } } diff --git a/p2p/types/src/global_ip.rs b/p2p/types/src/global_ip.rs index 74edfa5de5..273550ce95 100644 --- a/p2p/types/src/global_ip.rs +++ b/p2p/types/src/global_ip.rs @@ -83,6 +83,13 @@ impl IsGlobalIp for std::net::Ipv6Addr { (addr.segments()[0] == 0x2001) && (addr.segments()[1] == 0xdb8) } + // An IPv4-mapped address is semantically an IPv4 address; judge it by the embedded + // IPv4 rules. Without this, `::ffff:127.0.0.1` or `::ffff:10.0.0.1` pass the IPv6 + // checks despite representing non-routable IPv4 addresses. + if let Some(ipv4) = self.to_ipv4_mapped() { + return ipv4.is_global_unicast_ip(); + } + // Copied from the unstable method `std::net::Ipv6Addr::is_unicast_global`. !self.is_multicast() && !self.is_loopback() @@ -152,4 +159,28 @@ mod tests { ); } } + + #[test] + fn ipv4_mapped_ipv6_addresses_are_judged_by_the_embedded_ipv4() { + let non_global_mapped = [ + "::ffff:127.0.0.1", // Loopback + "::ffff:10.0.0.1", // Private + "::ffff:192.168.1.1", // Private + "::ffff:172.16.0.1", // Private + "::ffff:169.254.1.1", // Link-local + "::ffff:0.0.0.0", // Unspecified + "::ffff:224.0.0.1", // Multicast + ]; + for ip in non_global_mapped { + assert!( + !is_global_unicast(ip), + "{ip} is expected to be not global unicast IP address" + ); + } + + assert!( + is_global_unicast("::ffff:8.8.8.8"), + "mapped global IPv4 is expected to be global unicast IP address" + ); + } } diff --git a/p2p/types/src/peer_address.rs b/p2p/types/src/peer_address.rs index 72b777bf83..38a73897bb 100644 --- a/p2p/types/src/peer_address.rs +++ b/p2p/types/src/peer_address.rs @@ -13,10 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{ - fmt::Display, - net::{Ipv4Addr, Ipv6Addr}, -}; +use std::fmt::Display; use serialization::{Decode, Encode}; @@ -51,12 +48,22 @@ pub enum PeerAddress { } impl PeerAddress { + /// The IP address in canonical form, where an IPv4-mapped IPv6 address + /// (`::ffff:a.b.c.d`) is reduced to the embedded IPv4 address. + /// + /// Note that the derived `Eq` still distinguishes the mapped form from the plain + /// form, because the wire format keeps them distinct; deduplicate or key collections + /// on this canonical value rather than on the address itself. + pub fn canonical_ip(&self) -> std::net::IpAddr { + crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) + } + pub fn is_loopback(&self) -> bool { - std::net::SocketAddr::from(self).ip().is_loopback() + self.canonical_ip().is_loopback() } pub fn is_global_unicast_ip(&self) -> bool { - std::net::SocketAddr::from(self).ip().is_global_unicast_ip() + self.canonical_ip().is_global_unicast_ip() } /// If the address is eligible for being sent to peers via AddrListResponse, return Some, @@ -64,26 +71,21 @@ impl PeerAddress { /// /// The address is eligible if it has a public routable IP and any valid (non-zero) port. /// Private and local IPs are allowed if `allow_discover_private_ips` is true. + /// + /// IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form, so the eligibility + /// check sees the IPv4 address they represent and the returned address is the IPv4 form. pub fn as_discoverable_socket_address( &self, allow_discover_private_ips: bool, ) -> Option { - match self { - PeerAddress::Ip4(socket) - if (Ipv4Addr::from(socket.ip).is_global_unicast_ip() - || allow_discover_private_ips) - && socket.port != 0 => - { - Some(SocketAddress::new(self.into())) - } - PeerAddress::Ip6(socket) - if (Ipv6Addr::from(socket.ip).is_global_unicast_ip() - || allow_discover_private_ips) - && socket.port != 0 => - { - Some(SocketAddress::new(self.into())) - } - _ => None, + let std_address = + crate::socket_address::canonical_socket_address(std::net::SocketAddr::from(self)); + if (std_address.ip().is_global_unicast_ip() || allow_discover_private_ips) + && std_address.port() != 0 + { + Some(SocketAddress::new(std_address)) + } else { + None } } } @@ -126,3 +128,81 @@ impl From<&PeerAddress> for std::net::SocketAddr { } } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + + fn mapped_v6(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + let o = ip.octets(); + PeerAddress::Ip6(PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port, + }) + } + + fn plain_v4(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + PeerAddress::Ip4(PeerAddressIp4 { + ip: ip.into(), + port, + }) + } + + #[test] + fn mapped_non_routable_addresses_are_not_discoverable() { + let non_routable = [ + std::net::Ipv4Addr::LOCALHOST, + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(192, 168, 1, 1), + std::net::Ipv4Addr::new(172, 16, 0, 1), + std::net::Ipv4Addr::new(169, 254, 1, 1), + std::net::Ipv4Addr::UNSPECIFIED, + ]; + for ip in non_routable { + let address = mapped_v6(ip, 12345); + assert!( + address.as_discoverable_socket_address(false).is_none(), + "mapped {ip} must not pass the discoverable gate" + ); + // The plain form is rejected by the same gate, both forms must agree. + assert!(plain_v4(ip, 12345).as_discoverable_socket_address(false).is_none()); + } + } + + #[test] + fn mapped_routable_address_is_discoverable_in_the_ipv4_form() { + let address = mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345); + let socket_address = address + .as_discoverable_socket_address(false) + .expect("mapped global address must pass the discoverable gate"); + assert_eq!( + socket_address.socket_addr(), + std::net::SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::new(8, 8, 8, 8), + 12345 + )) + ); + } + + #[test] + fn zero_port_is_rejected_in_the_mapped_form() { + let address = mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 0); + assert!(address.as_discoverable_socket_address(false).is_none()); + } + + #[test] + fn mapped_loopback_is_detected_by_is_loopback() { + assert!(mapped_v6(std::net::Ipv4Addr::LOCALHOST, 12345).is_loopback()); + assert!(!mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345).is_loopback()); + } +} diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index db9a07fc17..98b1017575 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -19,7 +19,7 @@ use std::{ str::FromStr, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::{bannable_address::BannableAddress, peer_address::PeerAddress}; @@ -32,27 +32,38 @@ use crate::{bannable_address::BannableAddress, peer_address::PeerAddress}; PartialOrd, Ord, Serialize, - Deserialize, Hash, rpc_description::HasValueHint, )] pub struct SocketAddress(SocketAddr); +// Deserialize routes through the constructor so that mapped forms are folded on the way in, +// matching the wire format of the derived `Serialize`. +impl<'de> serde::Deserialize<'de> for SocketAddress { + fn deserialize>(deserializer: D) -> Result { + ::deserialize(deserializer).map(SocketAddress::new) + } +} + impl SocketAddress { + /// The stored address is canonical by construction: IPv4-mapped IPv6 forms are reduced + /// to the embedded IPv4 form, so classification, bans, deduplication and storage treat + /// `::ffff:a.b.c.d` as the IPv4 address it represents. pub fn new(addr: SocketAddr) -> Self { - Self(addr) + Self(canonical_socket_address(addr)) } pub fn socket_addr(&self) -> SocketAddr { self.0 } + /// The IP address of the canonical form; see [`SocketAddress::new`]. pub fn ip_addr(&self) -> IpAddr { self.0.ip() } pub fn as_bannable(&self) -> BannableAddress { - BannableAddress::new(self.0.ip()) + BannableAddress::new(self.ip_addr()) } pub fn as_peer_address(&self) -> PeerAddress { @@ -60,6 +71,42 @@ impl SocketAddress { } } +/// Reduce an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) to the embedded IPv4 address; +/// any other address is returned unchanged. +/// +/// Note that only the mapped form is reduced. Other IPv4-embedded encodings (6to4, +/// Teredo, NAT64, IPv4-translated) are treated as plain IPv6 addresses. +pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => IpAddr::V6(v6), + }, + v4 => v4, + } +} + +/// Same reduction as [`canonical_ip_addr`], applied to a socket address including its +/// port. Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are +/// preserved. Mapped forms are assumed to carry zero flowinfo/scope id (as RFC 4291 +/// requires), which are then dropped along with the IPv6 framing. +pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => { + debug_assert_eq!( + (v6.flowinfo(), v6.scope_id()), + (0, 0), + "mapped IPv4 form is assumed to carry no flowinfo or scope id", + ); + SocketAddr::new(IpAddr::V4(v4), v6.port()) + } + None => SocketAddr::V6(v6), + }, + v4 => v4, + } +} + impl Display for SocketAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { std::fmt::Display::fmt(&self.0, f) @@ -70,7 +117,7 @@ impl FromStr for SocketAddress { type Err = AddrParseError; fn from_str(s: &str) -> Result { - SocketAddr::from_str(s).map(SocketAddress) + SocketAddr::from_str(s).map(SocketAddress::new) } } @@ -79,3 +126,66 @@ impl From for SocketAddress { SocketAddress::new(value) } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + use std::net::SocketAddrV4; + + #[test] + fn mapped_addresses_are_canonicalized() { + let mapped: SocketAddr = "[::ffff:1.2.3.4]:12345".parse().unwrap(); + let socket_address = SocketAddress::new(mapped); + + assert_eq!( + socket_address.ip_addr(), + IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4)) + ); + assert_eq!( + socket_address.as_bannable(), + BannableAddress::new(IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4))) + ); + + // Non-mapped addresses are unchanged. + let plain_v6: SocketAddr = "[2a00:1450:4017:815::200e]:12345".parse().unwrap(); + assert_eq!( + SocketAddress::new(plain_v6).ip_addr(), + IpAddr::V6("2a00:1450:4017:815::200e".parse().unwrap()) + ); + } + + #[test] + fn canonical_socket_address_reduces_mapped_form() { + let mapped: SocketAddr = "[::ffff:127.0.0.1]:12345".parse().unwrap(); + assert_eq!( + canonical_socket_address(mapped), + SocketAddr::V4(SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 12345)) + ); + } + + #[test] + fn canonical_socket_address_preserves_v6_flowinfo_and_scope_id() { + let scoped = SocketAddr::V6(std::net::SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 12345, + 7, + 9, + )); + assert_eq!(canonical_socket_address(scoped), scoped); + } + + #[test] + fn mapped_forms_are_canonicalized_on_construction() { + let plain: SocketAddr = "1.2.3.4:12345".parse().unwrap(); + let mapped: SocketAddr = "[::ffff:1.2.3.4]:12345".parse().unwrap(); + assert_eq!(SocketAddress::new(mapped), SocketAddress::new(plain)); + assert_eq!( + "[::ffff:1.2.3.4]:12345".parse::().unwrap(), + SocketAddress::new(plain) + ); + assert_eq!( + serde_json::from_str::("\"[::ffff:1.2.3.4]:12345\"").unwrap(), + SocketAddress::new(plain) + ); + } +}