Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5d73115
fix(p2p): judge ipv4-mapped addresses by their embedded ipv4 form
nullPointerEnjoyer Oct 17, 2026
e645c64
fix(p2p): key bans on the canonical form of mapped addresses
nullPointerEnjoyer Oct 17, 2026
0f16b35
fix(p2p): preserve ipv6 scope in canonical address helper
nullPointerEnjoyer Oct 17, 2026
2a0bd9d
test(p2p): pin the untouched ipv6 form in the canonical helper
nullPointerEnjoyer Oct 17, 2026
22c6b46
fix(p2p): canonicalize socket addresses at construction
nullPointerEnjoyer Oct 17, 2026
35f5e14
fix(p2p): fold legacy mapped address rows on peer db load
nullPointerEnjoyer Oct 17, 2026
3f023bc
fix(p2p): keep legacy address rows from clobbering fresh ones
nullPointerEnjoyer Oct 17, 2026
0675d14
test(p2p): cover the ban rows in the legacy fold test
nullPointerEnjoyer Oct 17, 2026
0e72b1e
fix(p2p): hoist the legacy key helpers above their first use
nullPointerEnjoyer Oct 17, 2026
1cce2d8
fix(p2p): merge folded address rows and decode them safely
nullPointerEnjoyer Oct 17, 2026
4168ab9
fix(p2p): decode both folded address rows safely and pin the merge
nullPointerEnjoyer Oct 17, 2026
d88a80c
test(p2p): assert folded legacy ban rows are removed
nullPointerEnjoyer Oct 17, 2026
a56d180
refactor(p2p): skip the legacy fold commit and derive ban serialization
nullPointerEnjoyer Oct 17, 2026
09ec47b
docs(p2p): spell out the legacy fold contract and pin its return value
nullPointerEnjoyer Oct 17, 2026
5f9f166
docs(p2p): deduplicate the legacy fold contract and pin its commit
nullPointerEnjoyer Oct 17, 2026
a7c9b45
docs(p2p): note the grouping of other ipv4-embedded encodings
nullPointerEnjoyer Oct 17, 2026
e92126c
docs(p2p): complete the embedded encoding grouping note
nullPointerEnjoyer Oct 17, 2026
41009f7
test(p2p): pin the canonical ip helper and both merge directions
nullPointerEnjoyer Oct 17, 2026
51034ee
docs(p2p): correct the equality note on the canonical ip helper
nullPointerEnjoyer Oct 17, 2026
2c85f1e
refactor(p2p): single-source the canonical ip of a peer address
nullPointerEnjoyer Oct 17, 2026
34da41b
fix(p2p): harden the address fold paths and their documentation
nullPointerEnjoyer Oct 17, 2026
cbdd411
fix(p2p): name the affected table in the legacy fold warnings
nullPointerEnjoyer Oct 17, 2026
ab7cc9e
fix(p2p): pin mapped address assumptions and refine the key scan
nullPointerEnjoyer Oct 18, 2026
5633ad6
docs(p2p): note that ban keys normalize on serialization
nullPointerEnjoyer Oct 18, 2026
80caa41
fix(p2p): drop unparseable legacy mapped rows on peer db load
nullPointerEnjoyer Oct 18, 2026
544a5da
refactor(p2p): name the legacy mapped key prefixes
nullPointerEnjoyer Oct 18, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 59 additions & 12 deletions p2p/src/peer_manager/address_groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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])
);
}
}
8 changes: 6 additions & 2 deletions p2p/src/peer_manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions p2p/src/peer_manager/peerdb/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>;
}

// Note: here we want to say something like:
Expand Down
166 changes: 166 additions & 0 deletions p2p/src/peer_manager/peerdb/storage_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,172 @@ impl<B: storage::SharedBackend> PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> {
fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()> {
Ok(self.storage().get_mut::<DBAnchorAddresses, _>().del(address.to_string())?)
}

fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<bool> {
fn canonical_spelling<T: std::str::FromStr + std::fmt::Display>(
table: &'static str,
key: &str,
prefix: &str,
) -> LegacyKeyAction
where
<T as std::str::FromStr>::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::<T>() {
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::<DBKnownAddresses, _>(),
|key| {
canonical_spelling::<SocketAddress>(
"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::<DBAnchorAddresses, _>(),
|key| {
canonical_spelling::<SocketAddress>(
"anchor addresses",
key,
MAPPED_SOCKET_ADDRESS_PREFIX,
)
},
|_canonical, _legacy| (),
)?;
rewrote_any |= rekey_mapped_rows(
&mut storage.get_mut::<DBBannedAddresses, _>(),
|key| {
canonical_spelling::<BannableAddress>(
"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::<DBDiscouragedAddresses, _>(),
|key| {
canonical_spelling::<BannableAddress>(
"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<B, DBM>(
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<bool>
where
B: storage::SharedBackend,
DBM: storage::schema::DbMap<Key = String>,
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 =
<DBM::Value as serialization::DecodeAll>::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));
Comment on lines +256 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
Error handling is inconsistent between corrupt keys and corrupt values: a key that no longer parses is dropped with a warning specifically so it cannot 'fail the startup on every run', but a value that fails to decode aborts the entire migration (and thus startup) with InvalidStorageState, reproducing exactly the same permanent-startup-failure scenario this function was written to avoid. A single corrupt row value in a legacy mapped-key row will wedge every subsequent startup. Consider dropping such rows (with an error-level log) for consistency with the key-drop policy, or at least documenting why value corruption is treated as unrecoverable while key corruption is not.

Suggestion:

Suggested change
let value =
<DBM::Value as serialization::DecodeAll>::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 value = match <DBM::Value as serialization::DecodeAll>::decode_all(
&mut encoded.as_ref(),
) {
Ok(value) => value,
Err(err) => {
logging::log::error!(
"Dropping legacy {table} row keyed by {key}: undecodable value: {err}"
);
drops.push(key);
rewrote_any = true;
continue;
}
};
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 = <DBM::Value as serialization::DecodeAll>::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<B: storage::SharedBackend> PeerDbStorageRead for PeerDbStoreTxRo<'_, B> {
Expand Down
13 changes: 13 additions & 0 deletions p2p/src/peer_manager/peerdb/storage_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ impl LoadedStorage {
}

fn load_storage_v3<S: PeerDbStorage>(storage: &S) -> crate::Result<LoadedStorage> {
{
// 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()?;
Comment on lines +87 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The migration is inconsistent in how it handles corrupt legacy rows: an unparseable key is skipped with a warning, but an unparseable value returns a hard P2pError::InvalidStorageState from load_storage_v3, aborting the whole load path and preventing the node from starting over a single corrupt row. Since this scan runs on every startup, consider skipping (or logging and dropping) rows whose values fail to decode, mirroring the lenient key policy, or at minimum documenting why a hard failure is intended here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the strict behavior here: a corrupt value fails the whole load anyway through the normal read path (e.g. get_known_addresses in storage_impl.rs decodes every row via prefix_iter_decoded + process_results and maps failures to InvalidStorageState), so skipping only inside the repair pass would not get the node running — it would just move the failure a few lines down with less context. Failing in the fold also names the offending key.

// 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::<BTreeMap<_, _>>();
Expand Down
Loading
Loading