diff --git a/Cargo.lock b/Cargo.lock index 7f5b7e1d35..a610c52f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-verbosity-flag" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9b20c0dd58e4c2e991c8d203bbeb76c11304d1011659686b5b644bc29aa478" +dependencies = [ + "clap", + "log", +] + [[package]] name = "clap_builder" version = "4.5.2" @@ -3718,6 +3728,7 @@ dependencies = [ "getrandom 0.2.15", "instant", "libp2p-allow-block-list", + "libp2p-autonat", "libp2p-connection-limits", "libp2p-core", "libp2p-dcutr", @@ -3756,6 +3767,27 @@ dependencies = [ "void", ] +[[package]] +name = "libp2p-autonat" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95151726170e41b591735bf95c42b888fe4aa14f65216a9fbf0edcc04510586" +dependencies = [ + "async-trait", + "asynchronous-codec 0.6.2", + "futures", + "futures-timer", + "instant", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec 0.2.0", + "rand 0.8.5", + "tracing", +] + [[package]] name = "libp2p-connection-limits" version = "0.3.1" @@ -7134,6 +7166,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sn_nat_detection" +version = "0.1.0" +dependencies = [ + "clap", + "clap-verbosity-flag", + "color-eyre", + "futures", + "libp2p", + "sn_networking", + "tokio", + "tracing", + "tracing-log 0.2.0", + "tracing-subscriber", +] + [[package]] name = "sn_networking" version = "0.15.2" diff --git a/Cargo.toml b/Cargo.toml index e3c96bc3ad..ba276d8871 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "sn_faucet", "sn_logging", "sn_metrics", + "sn_nat_detection", "sn_networking", "sn_node", "node-launchpad", diff --git a/sn_nat_detection/Cargo.toml b/sn_nat_detection/Cargo.toml new file mode 100644 index 0000000000..a8365ccd9e --- /dev/null +++ b/sn_nat_detection/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ["MaidSafe Developers "] +description = "Safe Network NAT detection tool" +edition = "2021" +homepage = "https://maidsafe.net" +license = "GPL-3.0" +name = "sn_nat_detection" +readme = "README.md" +repository = "https://github.com/maidsafe/safe_network" +version = "0.1.0" + +[[bin]] +name = "detect-nat" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5.4", features = ["derive"] } +clap-verbosity-flag = "2.2.0" +color-eyre = { version = "0.6", default-features = false } +futures = "~0.3.13" +libp2p = { version = "0.53", features = ["tokio", "tcp", "noise", "yamux", "autonat", "identify", "macros", "upnp"] } +sn_networking = { path = "../sn_networking", version = "0.15.2" } +tokio = { version = "1.32.0", features = ["full"] } +tracing = { version = "~0.1.26" } +tracing-log = "0.2.0" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints] +workspace = true diff --git a/sn_nat_detection/src/behaviour/autonat.rs b/sn_nat_detection/src/behaviour/autonat.rs new file mode 100644 index 0000000000..be3e5834a4 --- /dev/null +++ b/sn_nat_detection/src/behaviour/autonat.rs @@ -0,0 +1,75 @@ +use libp2p::autonat; +use tracing::{debug, info, warn}; + +use crate::App; + +impl App { + pub(crate) fn on_event_autonat(&mut self, event: autonat::Event) { + match event { + autonat::Event::InboundProbe(event) => match event { + autonat::InboundProbeEvent::Request { + probe_id, + peer: peer_id, + addresses, + } => { + info!(?probe_id, %peer_id, ?addresses, "Received a request to probe peer") + } + autonat::InboundProbeEvent::Response { + probe_id, + peer: peer_id, + address, + } => { + debug!(?probe_id, %peer_id, ?address, "Successfully probed a peer"); + } + autonat::InboundProbeEvent::Error { + probe_id, + peer: peer_id, + error, + } => { + warn!(?probe_id, %peer_id, ?error, "Probing a peer failed") + } + }, + autonat::Event::OutboundProbe(event) => match event { + autonat::OutboundProbeEvent::Request { + probe_id, + peer: peer_id, + } => { + debug!(?probe_id, %peer_id, "Asking remote to probe us") + } + autonat::OutboundProbeEvent::Response { + probe_id, + peer: peer_id, + address, + } => { + info!(?probe_id, %peer_id, ?address, "Remote successfully probed (reached) us") + } + autonat::OutboundProbeEvent::Error { + probe_id, + peer: peer_id, + error, + } => { + // Ignore the `NoServer` error if we're a server ourselves. + if self.client_state.is_none() + && !matches!(error, autonat::OutboundProbeError::NoServer) + { + warn!( + ?probe_id, + ?peer_id, + ?error, + "A request for probing us has failed" + ) + } + } + }, + autonat::Event::StatusChanged { old, new } => { + info!( + ?new, + ?old, + confidence = self.swarm.behaviour().autonat.confidence(), + "AutoNAT status changed" + ); + self.check_state(); + } + } + } +} diff --git a/sn_nat_detection/src/behaviour/identify.rs b/sn_nat_detection/src/behaviour/identify.rs new file mode 100644 index 0000000000..8489034039 --- /dev/null +++ b/sn_nat_detection/src/behaviour/identify.rs @@ -0,0 +1,56 @@ +use libp2p::{autonat, identify}; +use sn_networking::multiaddr_is_global; +use tracing::{debug, info, warn}; + +use crate::{behaviour::PROTOCOL_VERSION, App}; + +impl App { + pub(crate) fn on_event_identify(&mut self, event: identify::Event) { + match event { + identify::Event::Received { peer_id, info } => { + debug!( + %peer_id, + protocols=?info.protocols, + observed_address=%info.observed_addr, + protocol_version=%info.protocol_version, + "Received peer info" + ); + + // Disconnect if peer has incompatible protocol version. + if info.protocol_version != PROTOCOL_VERSION { + warn!(%peer_id, "Incompatible protocol version. Disconnecting from peer."); + let _ = self.swarm.disconnect_peer_id(peer_id); + return; + } + + // Disconnect if peer has no AutoNAT support. + if !info + .protocols + .iter() + .any(|p| *p == autonat::DEFAULT_PROTOCOL_NAME) + { + warn!(%peer_id, "Peer does not support AutoNAT. Disconnecting from peer."); + let _ = self.swarm.disconnect_peer_id(peer_id); + return; + } + + info!(%peer_id, "Received peer info: confirmed it supports AutoNAT"); + + // If we're a client and the peer has (a) global listen address(es), + // add it as an AutoNAT server. + if self.client_state.is_some() { + for addr in info.listen_addrs.into_iter().filter(multiaddr_is_global) { + self.swarm + .behaviour_mut() + .autonat + .add_server(peer_id, Some(addr)); + } + } + self.check_state(); + } + identify::Event::Sent { .. } => { /* ignore */ } + identify::Event::Pushed { .. } => { /* ignore */ } + identify::Event::Error { .. } => { /* ignore */ } + } + } +} diff --git a/sn_nat_detection/src/behaviour/mod.rs b/sn_nat_detection/src/behaviour/mod.rs new file mode 100644 index 0000000000..4d28d42981 --- /dev/null +++ b/sn_nat_detection/src/behaviour/mod.rs @@ -0,0 +1,71 @@ +use libp2p::identity; +use libp2p::swarm::behaviour::toggle::Toggle; +use libp2p::swarm::NetworkBehaviour; +use std::time::Duration; + +use crate::CONFIDENCE_MAX; + +mod autonat; +mod identify; +mod upnp; + +pub(crate) const PROTOCOL_VERSION: &str = "/sn_nat_detection/0.1.0"; + +#[derive(NetworkBehaviour)] +pub(crate) struct Behaviour { + pub autonat: libp2p::autonat::Behaviour, + pub identify: libp2p::identify::Behaviour, + pub upnp: Toggle, +} + +impl Behaviour { + pub(crate) fn new( + local_public_key: identity::PublicKey, + client_mode: bool, + upnp: bool, + ) -> Self { + let far_future = Duration::MAX / 10; // `MAX` on itself causes overflows. This is a workaround. + Self { + autonat: libp2p::autonat::Behaviour::new( + local_public_key.to_peer_id(), + if client_mode { + libp2p::autonat::Config { + // Use dialed peers for probing. + use_connected: true, + // Start probing a few seconds after swarm init. This gives us time to connect to the dialed server. + // With UPnP enabled, give it a bit more time to possibly open up the port. + boot_delay: if upnp { + Duration::from_secs(7) + } else { + Duration::from_secs(3) + }, + // Reuse probe server immediately even if it's the only one. + throttle_server_period: Duration::ZERO, + retry_interval: Duration::from_secs(10), + // We do not want to refresh. + refresh_interval: far_future, + confidence_max: CONFIDENCE_MAX, + ..Default::default() + } + } else { + libp2p::autonat::Config { + // Do not ask for dial-backs, essentially putting us in server mode. + use_connected: false, + // Never start probing, as we are a server. + boot_delay: far_future, + ..Default::default() + } + }, + ), + identify: libp2p::identify::Behaviour::new( + libp2p::identify::Config::new( + PROTOCOL_VERSION.to_string(), + local_public_key.clone(), + ) + // Exchange information every 5 minutes. + .with_interval(Duration::from_secs(5 * 60)), + ), + upnp: upnp.then(libp2p::upnp::tokio::Behaviour::default).into(), + } + } +} diff --git a/sn_nat_detection/src/behaviour/upnp.rs b/sn_nat_detection/src/behaviour/upnp.rs new file mode 100644 index 0000000000..db39c5cbf9 --- /dev/null +++ b/sn_nat_detection/src/behaviour/upnp.rs @@ -0,0 +1,24 @@ +use libp2p::upnp; +use tracing::{debug, info}; +use tracing_log::log::error; + +use crate::App; + +impl App { + pub(crate) fn on_event_upnp(&mut self, event: upnp::Event) { + match event { + upnp::Event::NewExternalAddr(addr) => { + info!(%addr, "Successfully mapped UPnP port"); + } + upnp::Event::ExpiredExternalAddr(addr) => { + debug!(%addr, "External UPnP port mapping expired"); + } + upnp::Event::GatewayNotFound => { + error!("No UPnP gateway not found"); + } + upnp::Event::NonRoutableGateway => { + error!("UPnP gateway is not routable"); + } + } + } +} diff --git a/sn_nat_detection/src/main.rs b/sn_nat_detection/src/main.rs new file mode 100644 index 0000000000..dd721f2d21 --- /dev/null +++ b/sn_nat_detection/src/main.rs @@ -0,0 +1,404 @@ +// Copyright 2024 MaidSafe.net limited. +// +// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3. +// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed +// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. Please review the Licences for the specific language governing +// permissions and limitations relating to use of the SAFE Network Software. + +use clap::Parser; +use color_eyre::eyre::{eyre, Result}; +use futures::StreamExt; +use libp2p::autonat::NatStatus; +use libp2p::core::{multiaddr::Protocol, Multiaddr}; +use libp2p::swarm::SwarmEvent; +use libp2p::{noise, tcp, yamux}; +use std::collections::HashSet; +use std::net::Ipv4Addr; +use std::time::Duration; +use tracing::{debug, info, warn}; +use tracing_log::AsTrace; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; + +use behaviour::{Behaviour, BehaviourEvent}; + +mod behaviour; + +const CONFIDENCE_MAX: usize = 2; +const RETRY_INTERVAL: Duration = Duration::from_secs(10); + +/// A tool to detect NAT status of the machine. It can be run in server mode or client mode. +/// The program will exit with an error code if NAT status is determined to be private. +#[derive(Debug, Parser)] +#[clap(version, author, about)] +struct Opt { + /// Port to listen on. + /// + /// `0` causes the OS to assign a random available port. + #[clap(long, short, default_value_t = 0)] + port: u16, + + /// Servers to send dial-back requests to as a client, in a 'multiaddr' format. + /// + /// If no servers are provided, the program will run in server mode. + /// + /// A multiaddr looks like `/ip4/1.2.3.4/tcp/1200/tcp` where `1.2.3.4` is the IP and `1200` is the port. + /// Alternatively, the address can be written as `1.2.3.4:1200`. + /// + /// This argument can be provided multiple times to connect to multiple peers. + #[clap(name = "SERVER", value_name = "multiaddr", value_delimiter = ',', value_parser = parse_peer_addr)] + server_addr: Vec, + + /// Disable use of UPnP to open a port on the router, before detecting NAT status. + #[clap(long, short, default_value_t = false)] + no_upnp: bool, + + #[command(flatten)] + verbose: clap_verbosity_flag::Verbosity, +} + +#[tokio::main] +async fn main() -> Result<()> { + color_eyre::install()?; + + // Process command line arguments. + let opt = Opt::parse(); + + let registry = tracing_subscriber::registry().with(tracing_subscriber::fmt::layer()); + // Use `RUST_LOG` if set, else use the verbosity flag (where `-vvvv` is trace level). + let _ = if std::env::var_os("RUST_LOG").is_some() { + registry.with(EnvFilter::from_env("RUST_LOG")).try_init() + } else { + let filter = tracing_subscriber::filter::Targets::new().with_target( + env!("CARGO_BIN_NAME").replace('-', "_"), + opt.verbose.log_level_filter().as_trace(), + ); + registry.with(filter).try_init() + }; + + let mut builder = AppBuilder::new() + .servers(opt.server_addr) + .upnp(false) + .port(opt.port); + + // Run the program twice, to first detect NAT status without UPnP, + // and then with UPnP enabled. (Unless `--no-upnp` was given.) + let mut running_with_upnp = false; + loop { + let status = builder + .build()? + // The main loop will exit once it has gained enough confidence in the NAT status. + .run() + .await; + + match status { + NatStatus::Public(addr) => { + info!(%addr, "NAT is public{}", if running_with_upnp { " (with UPnP)" } else { "" }); + break Ok(()); + } + NatStatus::Private => { + // Unless `--no-upnp` is set, rerun the program with UPnP enabled. + if !opt.no_upnp && !running_with_upnp { + warn!("NAT is private, rerunning program with UPnP enabled in 2 seconds..."); + tokio::time::sleep(Duration::from_secs(2)).await; + builder = builder.upnp(true); + running_with_upnp = true; + } else { + break Err(eyre!("NAT is private")); + } + } + NatStatus::Unknown => break Err(eyre!("NAT is unknown")), + } + } +} + +enum State { + // This is where we start dialing the servers. + Init(Vec), + // When we're dialing, we'll move on once we've connected to a server. + Dialing, + // We start probing until we have enough confidence (`usize`). + // Keep track of confidence to report on changes. + Probing(usize), + // With enough confidence reached, we should report back the status. + Done(NatStatus), +} + +struct App { + swarm: libp2p::Swarm, + // Interval with which to check the state of the program. (State is also checked on events.) + interval: tokio::time::Interval, + // When we are a client, we progress through different states. + client_state: Option, + // Keep track of candidate addresses to avoid logging duplicates. + candidate_addrs: HashSet, +} + +impl App { + fn new(swarm: libp2p::Swarm, servers: Vec) -> Self { + Self { + swarm, + interval: tokio::time::interval(Duration::from_secs(5)), + client_state: if servers.is_empty() { + None + } else { + Some(State::Init(servers)) + }, + candidate_addrs: HashSet::new(), + } + } + + /// Run the event loop until we have gained enough confidence in the NAT status. + async fn run(mut self) -> NatStatus { + loop { + // Process both events and check the state per the interval. + tokio::select! { + event = self.swarm.select_next_some() => self.on_event(event), + _ = self.interval.tick() => self.check_state(), + } + + // If we reached `Done` status, return the status. + if let Some(State::Done(status)) = self.client_state { + break status; + } + } + } + + // Called regularly to check the state of the program. + fn check_state(&mut self) { + let state = if let Some(state) = self.client_state.take() { + state + } else { + return; + }; + + match state { + State::Init(servers) => { + self.client_state = Some(State::Dialing); + + for addr in servers { + // `SwarmEvent::Dialing` is only triggered when peer ID is included, so + // we log here too to make sure we log that we're dialing a server. + if let Err(e) = self.swarm.dial(addr.clone()) { + warn!(%addr, ?e, "Failed to dial server"); + } else { + info!(%addr, "Dialing server"); + } + } + } + State::Dialing => { + let info = self.swarm.network_info(); + if info.num_peers() > 0 { + self.client_state = Some(State::Probing(0)); + } else { + self.client_state = Some(State::Dialing); + } + } + State::Probing(old_confidence) => { + let confidence = self.swarm.behaviour().autonat.confidence(); + let status = self.swarm.behaviour().autonat.nat_status(); + + if confidence >= CONFIDENCE_MAX { + debug!(confidence, ?status, "probing complete"); + self.client_state = Some(State::Done(status)); + } else { + if confidence != old_confidence { + info!( + ?status, + %confidence, + "Confidence in NAT status {}", + if confidence > old_confidence { + "increased" + } else { + "decreased" + } + ); + } + self.client_state = Some(State::Probing(confidence)); + } + } + State::Done(status) => { + // Nothing more to do + self.client_state = Some(State::Done(status)); + } + } + } + + fn on_event(&mut self, event: SwarmEvent) { + match event { + // We delegate the specific behaviour events to their respective methods. + SwarmEvent::Behaviour(event) => match event { + BehaviourEvent::Identify(event) => self.on_event_identify(event), + BehaviourEvent::Autonat(event) => self.on_event_autonat(event), + BehaviourEvent::Upnp(event) => self.on_event_upnp(event), + }, + SwarmEvent::NewListenAddr { address, .. } => { + debug!(%address, "Listening on new address"); + } + SwarmEvent::NewExternalAddrCandidate { address } => { + // Only report on newly discovered addresses. + if self.candidate_addrs.insert(address.clone()) { + info!(%address, "New external address candidate"); + } + } + SwarmEvent::ExternalAddrConfirmed { address } => { + info!(%address, "External address confirmed"); + self.check_state(); + } + SwarmEvent::ExternalAddrExpired { address } => { + warn!(%address, "External address expired") + } + SwarmEvent::ConnectionEstablished { + peer_id, + num_established, + connection_id, + .. + } => { + debug!( + conn_id=%connection_id, + %peer_id, + count=num_established, + "Connected to peer{}", + if num_established.get() > 1 { + " (again)" + } else { + "" + } + ); + self.check_state(); + } + SwarmEvent::ConnectionClosed { + peer_id, + num_established, + connection_id, + cause, + .. + } => { + debug!(conn_id=%connection_id, %peer_id, count=num_established, ?cause, "Closed connection to peer"); + } + SwarmEvent::IncomingConnection { + local_addr, + send_back_addr, + connection_id, + .. + } => { + debug!(conn_id=%connection_id, %local_addr, %send_back_addr, "Incoming connection"); + } + SwarmEvent::IncomingConnectionError { + connection_id, + local_addr, + send_back_addr, + error, + .. + } => { + warn!(conn_id=%connection_id, %local_addr, %send_back_addr, ?error, "Incoming connection error"); + } + SwarmEvent::OutgoingConnectionError { + peer_id, + connection_id, + error, + .. + } => { + warn!(conn_id=%connection_id, ?peer_id, ?error, "Connection error"); + } + SwarmEvent::ExpiredListenAddr { .. } => { /* ignore */ } + SwarmEvent::ListenerClosed { .. } => { /* ignore */ } + SwarmEvent::ListenerError { .. } => { /* ignore */ } + SwarmEvent::Dialing { + peer_id, + connection_id, + } => { + info!(?peer_id, conn_id=%connection_id, "Dialing peer"); + } + SwarmEvent::NewExternalAddrOfPeer { .. } => { /* ignore */ } + event => warn!(?event, "Unknown SwarmEvent"), + } + } +} + +/// Parse strings like `1.2.3.4:1234` and `/ip4/1.2.3.4/tcp/1234` into a multiaddr. +fn parse_peer_addr(addr: &str) -> Result { + // Parse valid IPv4 socket address, e.g. `1.2.3.4:1234`. + if let Ok(addr) = addr.parse::() { + let multiaddr = Multiaddr::from(*addr.ip()).with(Protocol::Tcp(addr.port())); + + return Ok(multiaddr); + } + + // Parse any valid multiaddr string + if let Ok(addr) = addr.parse::() { + return Ok(addr); + } + + Err("Could not parse address") +} + +struct AppBuilder { + port: u16, + servers: Vec, + upnp: bool, +} + +impl AppBuilder { + fn new() -> Self { + Self { + port: 0, + upnp: false, + servers: vec![], + } + } + + fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + fn upnp(mut self, upnp: bool) -> Self { + self.upnp = upnp; + self + } + + fn servers(mut self, servers: Vec) -> Self { + self.servers = servers; + self + } + + fn build(&self) -> Result { + // If no servers are provided, we are in server mode. Conversely, with servers + // provided, we are in client mode. + let client_mode = !self.servers.is_empty(); + + let mut swarm = libp2p::SwarmBuilder::with_new_identity() + .with_tokio() + .with_tcp( + tcp::Config::default(), + noise::Config::new, + yamux::Config::default, + )? + .with_behaviour(|key| Behaviour::new(key.public(), client_mode, self.upnp))? + // Make it so that we retry just before idling out, to prevent quickly disconnecting/connecting + // to the same server. + .with_swarm_config(|c| { + c.with_idle_connection_timeout(RETRY_INTERVAL + Duration::from_secs(2)) + }) + .build(); + + swarm.listen_on( + Multiaddr::empty() + .with(Protocol::Ip4(Ipv4Addr::UNSPECIFIED)) + .with(Protocol::Tcp(self.port)), + )?; + + info!( + peer_id=%swarm.local_peer_id(), + "Starting in {} mode", + if client_mode { "client" } else { "server" } + ); + + let app = App::new(swarm, self.servers.clone()); + + Ok(app) + } +}