rules so only ipv4 can mine

This commit is contained in:
viraladmin 2026-07-25 08:50:39 -06:00
parent 9381914a2c
commit d06b4c83f0
11 changed files with 272 additions and 96 deletions

1
Cargo.lock generated
View File

@ -350,6 +350,7 @@ dependencies = [
"shellexpand",
"skein",
"sled",
"socket2",
"tokio",
"tokio-postgres",
"windows-service",

View File

@ -36,6 +36,7 @@ hex = "0.4"
falcon-rs = "0.2.4"
fn-dsa = "0.3.0"
tokio = { version = "1.45.1", features = ["full", "test-util"] }
socket2 = "0.5.7"
tokio-postgres = "0.7"
rand = "0.8"
sled = "0.34"

View File

@ -43,8 +43,15 @@ pub fn ip_to_binary(ip_str: &str) -> Vec<u8> {
bytes
}
Ok(IpAddr::V6(ipv6_addr)) => {
// IPv6 already fits the fixed 16-byte network layout.
ipv6_addr.octets().to_vec()
if let Some(ipv4_addr) = ipv6_addr.to_ipv4_mapped() {
// IPv4-mapped IPv6 is the same identity as native IPv4.
let mut bytes = vec![0u8; 12];
bytes.extend_from_slice(&ipv4_addr.octets());
bytes
} else {
// Native IPv6 already fits the fixed 16-byte layout.
ipv6_addr.octets().to_vec()
}
}
Err(_) => {
// Invalid IP strings are rejected by returning no bytes.
@ -123,7 +130,15 @@ pub fn ip_port_to_binary(ip_port: &str) -> Result<Vec<u8>, String> {
ipv6_bytes[12..].copy_from_slice(&ipv4.octets());
ipv6_bytes.to_vec()
}
IpAddr::V6(ipv6) => ipv6.octets().to_vec(),
IpAddr::V6(ipv6) => {
if let Some(ipv4) = ipv6.to_ipv4_mapped() {
let mut bytes = [0u8; 16];
bytes[12..].copy_from_slice(&ipv4.octets());
bytes.to_vec()
} else {
ipv6.octets().to_vec()
}
}
};
// Ports are little-endian because the rest of the binary protocol
@ -132,3 +147,26 @@ pub fn ip_port_to_binary(ip_port: &str) -> Result<Vec<u8>, String> {
result.extend_from_slice(&port.to_le_bytes());
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipv4_mapped_address_uses_native_ipv4_wire_identity() {
assert_eq!(
ip_to_binary("::ffff:203.0.113.25"),
ip_to_binary("203.0.113.25")
);
assert_eq!(
ip_port_to_binary("::ffff:203.0.113.25:50050").unwrap(),
ip_port_to_binary("203.0.113.25:50050").unwrap()
);
}
#[test]
fn native_ipv6_wire_format_remains_available_for_rpc_transport() {
assert_eq!(ip_to_binary("2001:db8::1").len(), 16);
assert_eq!(ip_port_to_binary("[2001:db8::1]:50055").unwrap().len(), 18);
}
}

View File

@ -1,44 +1,75 @@
use crate::Settings;
use ipnetwork::IpNetwork;
use std::net::{IpAddr, SocketAddr};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tokio::net::lookup_host;
fn endpoint_ip(ip_or_endpoint: &str) -> Option<IpAddr> {
if let Ok(ip) = ip_or_endpoint.parse::<IpAddr>() {
return Some(ip);
}
let host = ip_or_endpoint
.strip_prefix('[')
.and_then(|value| value.split_once(']').map(|(ip, _)| ip))
.or_else(|| ip_or_endpoint.rsplit_once(':').map(|(ip, _)| ip))
.unwrap_or(ip_or_endpoint);
host.parse::<IpAddr>().ok()
}
pub fn canonical_ipv4(ip: IpAddr) -> Option<Ipv4Addr> {
match ip {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(ip) => ip.to_ipv4_mapped(),
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
!ip.is_unspecified()
&& !ip.is_loopback()
&& !ip.is_private()
&& !ip.is_link_local()
&& !ip.is_multicast()
&& !ip.is_broadcast()
&& !(ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]))
}
pub fn is_public_network_address(ip_or_endpoint: &str) -> bool {
// These ranges cannot be used as advertised network addresses
// because remote peers cannot route to them over the public network.
let private_ranges: Vec<IpNetwork> = vec![
"127.0.0.0/8".parse().unwrap(),
"10.0.0.0/8".parse().unwrap(),
"172.16.0.0/12".parse().unwrap(),
"192.168.0.0/16".parse().unwrap(),
"100.64.0.0/10".parse().unwrap(),
"fd00::/8".parse().unwrap(),
"fe80::/10".parse().unwrap(),
];
match endpoint_ip(ip_or_endpoint) {
Some(IpAddr::V4(ip)) => is_public_ipv4(ip),
_ => false,
}
}
// Accept either a bare IP or an ip:port endpoint. Bare IPv6 addresses
// must be tested before splitting on the final port separator.
let parsed_ip = match ip_or_endpoint.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => {
let host = ip_or_endpoint
.strip_prefix('[')
.and_then(|value| value.split_once(']').map(|(ip, _)| ip))
.or_else(|| ip_or_endpoint.rsplit_once(':').map(|(ip, _)| ip))
.unwrap_or(ip_or_endpoint);
let Ok(ip) = host.parse::<IpAddr>() else {
return false;
};
ip
pub fn canonical_miner_endpoint(endpoint: &str) -> Option<String> {
let port = endpoint.rsplit_once(':')?.1.parse::<u16>().ok()?;
if port == 0 {
return None;
}
let ip = canonical_ipv4(endpoint_ip(endpoint)?)?;
is_public_ipv4(ip).then(|| SocketAddr::new(IpAddr::V4(ip), port).to_string())
}
fn is_routable_address(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_ipv4(ip),
IpAddr::V6(ip) => {
!ip.is_unspecified()
&& !ip.is_loopback()
&& !ip.is_unique_local()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()
}
};
!private_ranges
.iter()
.any(|range: &IpNetwork| range.contains(parsed_ip))
}
}
pub async fn get_connections() -> Vec<String> {
get_connections_for_transport(false).await
}
pub async fn get_node_connections() -> Vec<String> {
get_connections_for_transport(true).await
}
async fn get_connections_for_transport(ipv4_only: bool) -> Vec<String> {
// If settings cannot be loaded, startup simply has no piggyback peers.
let settings = match Settings::load() {
Ok(settings) => settings,
@ -50,7 +81,7 @@ pub async fn get_connections() -> Vec<String> {
let mut resolved_connections = Vec::new();
for server in settings.piggybacks {
if let Some(endpoint) = resolve_bootstrap_peer(&server, &default_port).await {
if let Some(endpoint) = resolve_bootstrap_peer(&server, &default_port, ipv4_only).await {
resolved_connections.push(endpoint);
}
@ -97,11 +128,15 @@ fn split_bootstrap_peer(server: &str, default_port: &str) -> Option<(String, u16
Some((server.to_string(), default_port))
}
async fn resolve_bootstrap_peer(server: &str, default_port: &str) -> Option<String> {
async fn resolve_bootstrap_peer(
server: &str,
default_port: &str,
ipv4_only: bool,
) -> Option<String> {
let (host, port) = split_bootstrap_peer(server, default_port)?;
if let Ok(ip) = host.parse::<IpAddr>() {
if is_public_network_address(&ip.to_string()) {
if (!ipv4_only || canonical_ipv4(ip).is_some()) && is_routable_address(ip) {
return Some(SocketAddr::new(ip, port).to_string());
}
return None;
@ -111,7 +146,8 @@ async fn resolve_bootstrap_peer(server: &str, default_port: &str) -> Option<Stri
// The resolved endpoint handed to the rest of the protocol is still an IP.
let resolved = lookup_host((host.as_str(), port)).await.ok()?;
for socket in resolved {
if is_public_network_address(&socket.ip().to_string()) {
if (!ipv4_only || canonical_ipv4(socket.ip()).is_some()) && is_routable_address(socket.ip())
{
return Some(socket.to_string());
}
}
@ -163,3 +199,33 @@ pub async fn get_listen_ip() -> String {
// selection to the operating system and NAT/router.
settings.listen_ip
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn miner_endpoint_normalizes_ipv4_mapped_ipv6() {
assert_eq!(
canonical_miner_endpoint("::ffff:8.8.8.8:50050"),
Some("8.8.8.8:50050".to_string())
);
}
#[test]
fn miner_endpoint_rejects_native_ipv6_and_client_port() {
assert_eq!(
canonical_miner_endpoint("[2001:4860:4860::8888]:50050"),
None
);
assert_eq!(canonical_miner_endpoint("8.8.8.8:0"), None);
}
#[test]
fn network_map_requires_canonical_public_ipv4() {
assert!(is_public_network_address("8.8.8.8"));
assert!(!is_public_network_address("::ffff:8.8.8.8"));
assert!(!is_public_network_address("2001:4860:4860::8888"));
assert!(!is_public_network_address("192.168.1.10"));
}
}

View File

@ -1,4 +1,5 @@
use crate::common::check_genesis::genesis_checkup;
use crate::common::network_startup::canonical_miner_endpoint;
use crate::log::{error, warn};
use crate::miner::flag::{begin_chain_sync, ChainOperationGuard};
use crate::orphans::structs::OrphanCheckup2;
@ -9,6 +10,7 @@ use crate::records::memory::connections::{
mark_peer_network_map_synced, mark_peer_operational, mark_peer_wallet_registry_synced,
spawn_peer_setup_retry,
};
use crate::records::memory::enums::ClientType;
use crate::records::memory::response_channels::generate_uid;
use crate::records::memory::response_channels::Command;
use crate::records::memory::structs::Connection;
@ -414,6 +416,24 @@ pub async fn handle_handshake(
// aced is used instead of ping and pong
// as its HEX and compressed better
if received_message == "aced" {
// Port 0 is the explicit RPC-client marker. Classify the socket
// before running miner-only public-address checks.
let Some(advertised_port) = endpoint_port(&received_ip) else {
let hashmap_key = generate_uid();
let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0];
let uid = u32::from_le_bytes(padded_bytes);
RpcResponse::Binary(b"error: Invalid advertised endpoint.".to_vec())
.send(&stream, None, uid)
.await;
drop_failed_handshake(&stream).await;
return;
};
let client_type = if advertised_port == 0 {
ClientType::Client
} else {
ClientType::Miner
};
//validate handshake tests
if !perform_handshake_tests(HandshakeTestParams {
map: map.clone(),
@ -426,6 +446,7 @@ pub async fn handle_handshake(
received_signed_message: &received_signed_message,
received_address: &received_public_key,
received_ip: &received_ip,
client_type,
})
.await
{
@ -435,36 +456,46 @@ pub async fn handle_handshake(
return;
}
// Port 0 is the explicit client marker. A node advertising a
// nonzero miner port must actually be reachable before it can be
// stored in connection memory or the network map.
let Some(advertised_port) = endpoint_port(&received_ip) else {
let hashmap_key = generate_uid();
let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0];
let uid = u32::from_le_bytes(padded_bytes);
let response_bytes =
RpcResponse::Binary("error: Invalid advertised endpoint.".as_bytes().to_vec());
response_bytes.send(&stream, None, uid).await;
drop_failed_handshake(&stream).await;
return;
};
let connection_type = if advertised_port == 0 {
"client"
} else if is_port_open(&received_ip).await.unwrap_or(false) {
"miner"
let (connection_type, connections_endpoint) = if client_type == ClientType::Client {
let observed_endpoint = {
let stream_guard = stream.lock().await;
stream_guard
.peer_addr()
.ok()
.map(|address| address.to_string())
};
let Some(observed_endpoint) = observed_endpoint else {
drop_failed_handshake(&stream).await;
return;
};
("client", observed_endpoint)
} else {
let hashmap_key = generate_uid();
let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0];
let uid = u32::from_le_bytes(padded_bytes);
let response_bytes = RpcResponse::Binary(
"error: Handshake failed: advertised miner port is not reachable."
.as_bytes()
.to_vec(),
);
response_bytes.send(&stream, None, uid).await;
drop_failed_handshake(&stream).await;
return;
let Some(canonical_endpoint) = canonical_miner_endpoint(&received_ip) else {
let hashmap_key = generate_uid();
let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0];
let uid = u32::from_le_bytes(padded_bytes);
RpcResponse::Binary(
b"error: Miner nodes must advertise a public IPv4 address.".to_vec(),
)
.send(&stream, None, uid)
.await;
drop_failed_handshake(&stream).await;
return;
};
if !is_port_open(&canonical_endpoint).await.unwrap_or(false) {
let hashmap_key = generate_uid();
let padded_bytes = [hashmap_key[0], hashmap_key[1], hashmap_key[2], 0];
let uid = u32::from_le_bytes(padded_bytes);
let response_bytes = RpcResponse::Binary(
"error: Handshake failed: advertised miner port is not reachable."
.as_bytes()
.to_vec(),
);
response_bytes.send(&stream, None, uid).await;
drop_failed_handshake(&stream).await;
return;
}
("miner", canonical_endpoint)
};
if connection_type == "miner" {
@ -508,7 +539,7 @@ pub async fn handle_handshake(
// write to memory
let connections_key = write_to_memory(
&received_ip,
&connections_endpoint,
stream.clone(),
connection_type,
received_short_address,

View File

@ -1,4 +1,5 @@
use crate::records::memory::connections::CONNECTIONS;
use crate::records::memory::enums::ClientType;
use crate::records::memory::response_channels::{generate_uid, Command};
use crate::rpc::responses::RpcResponse;
use crate::rpc::server::structs::HandshakeTestParams;
@ -160,15 +161,18 @@ pub async fn perform_handshake_tests(params: HandshakeTestParams<'_>) -> bool {
{
return false;
}
// verify IP
if !verify_ip_address(
params.map.clone(),
params.stream.clone(),
params.received_ip,
)
.await
{
return false;
// RPC clients are identified by the accepted socket. Only miners
// advertise a public endpoint that must match the connected peer.
if params.client_type == ClientType::Miner {
if !verify_ip_address(
params.map.clone(),
params.stream.clone(),
params.received_ip,
)
.await
{
return false;
}
}
// verify handshake
if !verify_handshake(

View File

@ -1,4 +1,4 @@
use crate::log::error;
use crate::log::{error, warn};
use crate::records::memory::response_channels::Command;
use crate::rpc::server::handshake::handle_handshake;
use crate::sled::Db;
@ -7,6 +7,7 @@ use crate::Arc;
use crate::Mutex;
use crate::SocketAddr;
use crate::TcpListener;
use socket2::{Domain, Protocol, Socket, Type};
// wait incomming connections
pub async fn start_rpc(
@ -45,6 +46,44 @@ async fn rpc_server(
return;
}
};
// IPv4 remains the node transport and advertised identity. A second
// IPv6-only listener accepts RPC clients on the same configured port;
// the handshake rejects any IPv6 socket that advertises a miner port.
if server_socket.is_ipv4() {
let ipv6_listener = (|| -> std::io::Result<TcpListener> {
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))?;
socket.set_only_v6(true)?;
let address = SocketAddr::new("::".parse().unwrap(), server_socket.port());
socket.bind(&address.into())?;
socket.listen(1024)?;
let listener: std::net::TcpListener = socket.into();
listener.set_nonblocking(true)?;
TcpListener::from_std(listener)
})();
match ipv6_listener {
Ok(ipv6_listener) => {
let ipv6_db = db.clone();
let ipv6_wallet = wallet.clone();
let ipv6_map = map.clone();
tokio::spawn(async move {
accept_connections(ipv6_listener, ipv6_db, ipv6_wallet, ipv6_map).await;
});
}
Err(err) => {
warn!("IPv6 RPC client listener unavailable: {err}");
}
}
}
accept_connections(listener, db.clone(), wallet, map).await;
}
async fn accept_connections(
listener: TcpListener,
db: Db,
wallet: Arc<Wallet>,
map: Arc<Mutex<Command>>,
) {
loop {
match listener.accept().await {
Ok((stream, _)) => {

View File

@ -40,6 +40,7 @@ pub struct HandshakeTestParams<'a> {
pub received_signed_message: &'a str,
pub received_address: &'a str,
pub received_ip: &'a str,
pub client_type: ClientType,
}
// RpcFloodState is stored as a compact sled value so flood counters can

View File

@ -1,4 +1,4 @@
use crate::common::network_startup::is_public_network_address;
use crate::common::network_startup::{canonical_ipv4, canonical_miner_endpoint};
use crate::Arc;
use crate::Mutex;
use crate::SocketAddr;
@ -14,22 +14,20 @@ pub fn endpoint_port(ip_port: &str) -> Option<u16> {
pub async fn ip_test(ip_port: &str, stream: &Arc<Mutex<TcpStream>>) -> bool {
// Private, loopback, malformed, and otherwise non-public addresses
// are rejected before comparing them with the connected socket.
if !is_public_network_address(ip_port) {
let Some(canonical_endpoint) = canonical_miner_endpoint(ip_port) else {
return false;
}
};
let stream_guard = stream.lock().await;
let remote_addr = stream_guard.peer_addr();
// Strip the port from either IPv4 host:port or bracketed IPv6
// [host]:port forms before comparing against the socket address.
let submitted_ip = ip_port
.strip_prefix('[')
.and_then(|value| value.split_once(']').map(|(ip, _)| ip))
.or_else(|| ip_port.rsplit_once(':').map(|(ip, _)| ip))
.unwrap_or(ip_port);
let submitted_ip = canonical_endpoint
.rsplit_once(':')
.and_then(|(ip, _)| ip.parse().ok());
remote_addr.is_ok_and(|addr| addr.ip().to_string() == submitted_ip)
remote_addr.is_ok_and(|addr| canonical_ipv4(addr.ip()) == submitted_ip)
}
// check if port is open

View File

@ -1,5 +1,4 @@
use crate::common::binary_conversions::ip_port_to_binary;
use crate::common::network_startup::get_ip_and_port;
use crate::common::skein::skein_256_hash_data;
use crate::io;
use crate::records::memory::response_channels::Byte3;
@ -70,11 +69,9 @@ pub async fn connect_and_handshake(
}
async fn get_client_ip() -> String {
// The standalone client announces its local IP with port 0 because it is not opening a server port.
let (_port, _ip, real_ip) = get_ip_and_port().await;
let parts: Vec<&str> = real_ip.split(':').collect();
let ip = parts.first().copied().unwrap_or("127.0.0.1");
format!("{ip}:0")
// Port 0 identifies an RPC client. The server derives the client's
// actual IPv4 or IPv6 address from the accepted socket.
"0.0.0.0:0".to_string()
}
async fn perform_handshake(

View File

@ -1,4 +1,4 @@
use crate::common::network_startup::get_connections;
use crate::common::network_startup::get_node_connections;
use crate::log::{error, info, warn};
use crate::miner::flag::{
clear_mining_stop_request, is_normal_mode, set_mining_state, set_node_mode, MiningState,
@ -55,7 +55,7 @@ async fn attempt_bootstrap_connections(
) -> Result<bool, String> {
// Try the configured bootstrap peers one by one until a
// handshake succeeds or the list is exhausted.
let filtered_servers = get_connections().await;
let filtered_servers = get_node_connections().await;
let mut last_error: Option<String> = None;
for server in filtered_servers {