diff --git a/Cargo.lock b/Cargo.lock index 6db3d172571..a3f7babe007 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8050,6 +8050,7 @@ dependencies = [ "bip39", "bytecodec", "bytes", + "celes", "clap", "dashmap", "dirs", diff --git a/clients/socks5/src/main.rs b/clients/socks5/src/main.rs index 36161d3dcdf..d8bb33ffda1 100644 --- a/clients/socks5/src/main.rs +++ b/clients/socks5/src/main.rs @@ -19,6 +19,7 @@ async fn main() -> Result<(), Box> { if !args.no_banner { maybe_print_banner(crate_name!(), crate_version!()); } + setup_tracing_logger(); if let Err(err) = commands::execute(args).await { diff --git a/common/socks5-client-core/src/lib.rs b/common/socks5-client-core/src/lib.rs index 17d2205fe84..02c5613a51a 100644 --- a/common/socks5-client-core/src/lib.rs +++ b/common/socks5-client-core/src/lib.rs @@ -23,6 +23,7 @@ use nym_task::{ShutdownManager, ShutdownTracker}; use nym_validator_client::UserAgent; use std::error::Error; use std::path::PathBuf; +use tokio::net::TcpListener; pub mod config; pub mod error; @@ -95,6 +96,7 @@ where #[allow(clippy::too_many_arguments)] pub fn start_socks5_listener( socks5_config: &config::Socks5, + listener: TcpListener, base_debug: DebugConfig, client_input: ClientInput, client_output: ClientOutput, @@ -104,6 +106,7 @@ where packet_type: PacketType, ) { info!("Starting socks5 listener..."); + info!("Listening on {}", socks5_config.bind_address); let auth_methods = vec![AuthenticationMethods::NoAuth as u8]; let allowed_users: Vec = Vec::new(); @@ -129,7 +132,6 @@ where let authenticator = Authenticator::new(auth_methods, allowed_users); let mut sphinx_socks = NymSocksServer::new( - socks5_config.bind_address, authenticator, socks5_config.get_provider_mix_address(), self_address, @@ -147,6 +149,7 @@ where nym_task::spawn_future(async move { sphinx_socks .serve( + listener, input_sender, received_buffer_request_sender, connection_command_sender, @@ -208,6 +211,10 @@ where } pub async fn start(self) -> Result { + // Bind the listener before starting the base client so a port collision + // fails here, before we spin up (and would have to tear down) the mixnet. + let listener = TcpListener::bind(self.config.socks5.bind_address).await?; + // don't create dkg client for the bandwidth controller if credentials are disabled let dkg_query_client = if self.config.base.client.disabled_credentials_mode { None @@ -236,6 +243,7 @@ where Self::start_socks5_listener( &self.config.socks5, + listener, self.config.base().debug, client_input, client_output, diff --git a/common/socks5-client-core/src/socks/server.rs b/common/socks5-client-core/src/socks/server.rs index 278435a9df5..a259730da75 100644 --- a/common/socks5-client-core/src/socks/server.rs +++ b/common/socks5-client-core/src/socks/server.rs @@ -13,14 +13,11 @@ use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::params::PacketType; use nym_task::connections::{ConnectionCommandSender, LaneQueueLengths}; use nym_task::ShutdownTracker; -use std::net::SocketAddr; -use tap::TapFallible; use tokio::net::TcpListener; /// A Socks5 server that listens for connections. pub struct NymSocksServer { authenticator: Authenticator, - listening_address: SocketAddr, service_provider: Recipient, self_address: Recipient, client_config: client::Config, @@ -31,9 +28,7 @@ pub struct NymSocksServer { impl NymSocksServer { /// Create a new SphinxSocks instance - #[allow(clippy::too_many_arguments)] pub(crate) fn new( - bind_address: SocketAddr, authenticator: Authenticator, service_provider: Recipient, self_address: Recipient, @@ -42,10 +37,8 @@ impl NymSocksServer { shutdown: ShutdownTracker, packet_type: PacketType, ) -> Self { - info!("Listening on {bind_address}"); NymSocksServer { authenticator, - listening_address: bind_address, service_provider, self_address, client_config, @@ -59,13 +52,11 @@ impl NymSocksServer { /// connects to the server. pub(crate) async fn serve( &mut self, + listener: TcpListener, input_sender: InputMessageSender, buffer_requester: ReceivedBufferRequestSender, client_connection_tx: ConnectionCommandSender, ) -> Result<(), Socks5ClientCoreError> { - let listener = TcpListener::bind(self.listening_address) - .await - .tap_err(|err| log::error!("Failed to bind to address: {err}"))?; info!("Serving Connections..."); // controller for managing all active connections diff --git a/sdk/rust/nym-sdk/Cargo.toml b/sdk/rust/nym-sdk/Cargo.toml index fd691d0b9f3..ea9290e5afc 100644 --- a/sdk/rust/nym-sdk/Cargo.toml +++ b/sdk/rust/nym-sdk/Cargo.toml @@ -59,6 +59,7 @@ nym-bin-common = { workspace = true, features = [ bytecodec = { workspace = true } httpcodec = { workspace = true } bytes = { workspace = true } +celes = { workspace = true } http = { workspace = true } zeroize = { workspace = true } diff --git a/sdk/rust/nym-sdk/examples/socks5.rs b/sdk/rust/nym-sdk/examples/socks5.rs index 77e6952c92a..3f316c1249e 100644 --- a/sdk/rust/nym-sdk/examples/socks5.rs +++ b/sdk/rust/nym-sdk/examples/socks5.rs @@ -1,54 +1,41 @@ -//! SOCKS5 proxy client that routes HTTP requests through the mixnet. +//! SOCKS5 proxy client that routes an HTTPS request through the mixnet. //! -//! Connects a `Socks5MixnetClient` to a receiving `MixnetClient` acting -//! as the service provider, then sends an HTTP GET via the SOCKS5 proxy. +//! Picks a network requester (the mixnet exit), starts a local SOCKS5 listener, +//! points an HTTP client at it, and makes a real request end to end. //! //! Run with: cargo run --example socks5 -use nym_sdk::mixnet; +use nym_sdk::mixnet::{NetworkRequester, Socks5MixnetClient}; #[tokio::main] -async fn main() { +async fn main() -> Result<(), Box> { nym_bin_common::logging::setup_tracing_logger(); - // Connect a receiving client (acts as the "network requester"). - println!("Connecting receiver"); - let mut receiving_client = mixnet::MixnetClient::connect_new().await.unwrap(); - - // Build and connect a SOCKS5 sending client pointed at the receiver. - let socks5_config = mixnet::Socks5::new(receiving_client.nym_address().to_string()); - let sending_client = mixnet::MixnetClientBuilder::new_ephemeral() - .socks5_config(socks5_config) - .build() - .unwrap(); - - println!("Connecting sender"); - let sending_client = sending_client.connect_to_mixnet_via_socks5().await.unwrap(); - - // Configure an HTTP client to use the SOCKS5 proxy. - let proxy = reqwest::Proxy::all(sending_client.socks5_url()).unwrap(); - let reqwest_client = reqwest::Client::builder().proxy(proxy).build().unwrap(); - - // Send an HTTP request through the mixnet via SOCKS5. - // No network requester is running on the other end, so we won't - // get a real HTTP response — but the receiver sees the raw bytes. - tokio::spawn(async move { - println!("Sending socks5-wrapped http request"); - reqwest_client.get("https://nymtech.net").send().await.ok() - }); - - // The receiver sees the raw SOCKS5/HTTP bytes arrive. - println!("Waiting for message"); - if let Some(received) = receiving_client.wait_for_messages().await { - for r in received { - println!( - "Received socks5 message requesting for endpoint: {}", - String::from_utf8_lossy(&r.message[10..27]) - ); - } - } - - // Disconnect both clients. - receiving_client.disconnect().await; - sending_client.disconnect().await; + // How to choose the requester. `any()` auto-discovers one from the directory, + // weighted by performance, and is the most robust default. The alternatives: + // NetworkRequester::in_countries(["CH", "DE"])? -> discovery pinned to those countries + // NetworkRequester::exact(Entztfv6Uaz2hpYHQJ6JKoaCTpDL5dja18SuQWVJAmmx.Cvhn9rBJw5Ay9wgHcbgCnVg89MPSV5s2muPV2YF1BXYu@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf)? -> a specific known requester + // If you already have the address, Socks5MixnetClient::connect_new(Entztfv6Uaz2hpYHQJ6JKoaCTpDL5dja18SuQWVJAmmx.Cvhn9rBJw5Ay9wgHcbgCnVg89MPSV5s2muPV2YF1BXYu@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf) is the one-line shorthand for the `exact` case. + let requester = NetworkRequester::any(); + + // Passing `None` binds the SOCKS5 listener to the default 127.0.0.1:1080. Pass Some(addr) + // to move it, for example when that port is taken or to run more than one client. + println!("Selecting a network requester and connecting"); + let client = + Socks5MixnetClient::connect_with(requester, Some("127.0.0.1:1081".parse()?)).await?; + println!("SOCKS5 proxy listening at {}", client.socks5_url()); + + // Point an HTTP client at the proxy and make a request through the mixnet. + let proxy = reqwest::Proxy::all(client.socks5_url())?; + let http = reqwest::Client::builder().proxy(proxy).build()?; + + // nymtech.net is on the default Nym exit policy. If you change this URL to a + // destination the exit policy does not allow, the request fails at the exit, + // which is not a discovery failure. + println!("Sending request through the mixnet"); + let status = http.get("https://nymtech.net").send().await?.status(); + println!("Got response status: {status}"); + + client.disconnect().await; + Ok(()) } diff --git a/sdk/rust/nym-sdk/src/error.rs b/sdk/rust/nym-sdk/src/error.rs index 499ee4618ec..98d992f8dde 100644 --- a/sdk/rust/nym-sdk/src/error.rs +++ b/sdk/rust/nym-sdk/src/error.rs @@ -139,6 +139,18 @@ pub enum Error { #[error("no available gateway")] NoGatewayAvailable, + #[error("invalid ISO 3166 alpha-2 country code: {0}")] + InvalidCountryCode(String), + + #[error("no available gateway in the requested countries")] + NoGatewayInCountries, + + #[error("no countries specified; use NetworkRequester::any() to accept any country")] + NoCountriesSpecified, + + #[error("invalid network requester address: {0}")] + InvalidRecipientAddress(String), + #[error("tunnel disconnected by IPR")] IprTunnelDisconnected, diff --git a/sdk/rust/nym-sdk/src/mixnet.rs b/sdk/rust/nym-sdk/src/mixnet.rs index ca43fd73184..72abb732bc2 100644 --- a/sdk/rust/nym-sdk/src/mixnet.rs +++ b/sdk/rust/nym-sdk/src/mixnet.rs @@ -92,7 +92,7 @@ pub use native_client::MixnetClient; pub use native_client::MixnetClientSender; pub use paths::StoragePaths; pub use sink::{MixnetMessageSink, MixnetMessageSinkTranslator}; -pub use socks5_client::Socks5MixnetClient; +pub use socks5_client::{NetworkRequester, Socks5MixnetClient}; pub use stream::{MixnetListener, MixnetStream, StreamId}; pub use traits::MixnetMessageSender; diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index 0eba3d054b2..ba67f873e3c 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -38,6 +38,7 @@ use std::path::Path; use std::path::PathBuf; #[cfg(unix)] use std::sync::Arc; +use tokio::net::TcpListener; use url::Url; use zeroize::Zeroizing; @@ -905,6 +906,10 @@ where .socks5_config .clone() .ok_or(Error::Socks5Config { set: false })?; + // Bind the local listener before starting the mixnet client: a port + // collision then fails here, loudly, without spinning up (and leaking) a + // mixnet client we would immediately discard. + let listener = TcpListener::bind(socks5_config.bind_address).await?; let debug_config = self.config.debug_config; let packet_type = self.config.debug_config.traffic.packet_type; let (mut started_client, nym_address) = self.connect_to_mixnet_common().await?; @@ -915,6 +920,7 @@ where nym_socks5_client_core::NymClient::::start_socks5_listener( &socks5_config, + listener, debug_config, client_input, client_output, diff --git a/sdk/rust/nym-sdk/src/mixnet/socks5_client.rs b/sdk/rust/nym-sdk/src/mixnet/socks5_client.rs index 9dc649f7ddf..064fb41b551 100644 --- a/sdk/rust/nym-sdk/src/mixnet/socks5_client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/socks5_client.rs @@ -1,3 +1,4 @@ +use std::net::SocketAddr; use std::time::Duration; use nym_client_core::client::base_client::ClientState; @@ -5,24 +6,33 @@ use nym_socks5_client_core::config::Socks5; use nym_sphinx::addressing::clients::Recipient; use nym_task::connections::LaneQueueLengths; use nym_task::ShutdownTracker; -use tokio::sync::RwLockReadGuard; - use nym_topology::{NymRouteProvider, NymTopology, NymTopologyError}; use crate::mixnet::client::MixnetClientBuilder; -use crate::Result; +use crate::{Error, Result}; + +use celes::Country; +use tokio::sync::RwLockReadGuard; /// A SOCKS5 proxy client connected to the Nym mixnet. /// /// `Socks5MixnetClient` provides a SOCKS5 proxy interface to the Nym mixnet, /// allowing HTTP(S) clients and other SOCKS5-compatible applications to route -/// their traffic through the mixnet for enhanced privacy. +/// their traffic through the mixnet without having to modify their networking +/// code. +/// +/// Traffic leaves the mixnet through a network requester: a service running on +/// an Exit Gateway that makes requests on the client's behalf and enforces the +/// Nym exit policy. You can let the client discover one for you or name a specific +/// one; see [`connect_with`](Self::connect_with) and [`NetworkRequester`]. /// /// ## Usage /// -/// 1. Connect to a service provider via [`connect_new`](Self::connect_new) +/// 1. Connect, either by discovering a requester with +/// [`connect_with`](Self::connect_with) or naming a known one with +/// [`connect_new`](Self::connect_new) /// 2. Get the SOCKS5 URL via [`socks5_url`](Self::socks5_url) -/// 3. Configure your HTTP client to use this SOCKS5 proxy +/// 3. Point your HTTP client at that SOCKS5 proxy /// /// ## Example /// @@ -31,7 +41,7 @@ use crate::Result; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { -/// // Connect to a network requester service provider +/// // Connect to a known network requester by address /// let client = Socks5MixnetClient::connect_new("provider_nym_address...").await?; /// /// // Get the SOCKS5 proxy URL @@ -46,13 +56,7 @@ use crate::Result; /// client.disconnect().await; /// Ok(()) /// } -/// ``` -/// -/// ## Service Providers -/// -/// The SOCKS5 client connects to a "network requester" service provider that -/// makes HTTP requests on behalf of the client. The service provider's Nym -/// address must be provided when creating the client. +// ``` pub struct Socks5MixnetClient { /// The nym address of this connected client. pub(crate) nym_address: Recipient, @@ -61,7 +65,7 @@ pub struct Socks5MixnetClient { /// current message send queue length. pub(crate) client_state: ClientState, - /// The task manager that controls all the spawned tasks that the clients uses to do it's job. + /// The task manager controlling all the spawned tasks the client uses to do its job. pub(crate) task_handle: ShutdownTracker, /// SOCKS5 configuration parameters. @@ -69,9 +73,13 @@ pub struct Socks5MixnetClient { } impl Socks5MixnetClient { - /// Create a new client and connect to a service provider over the mixnet via SOCKS5 using + /// Create a new client and connect to a network requester over the mixnet via SOCKS5 using /// ephemeral in-memory keys that are discarded at application close. /// + /// This is the zero-ceremony path when you already know the requester's + /// address; it is shorthand for [`connect_with`](Self::connect_with) with + /// [`NetworkRequester::exact`] and the default listener bind. + /// /// # Examples /// /// ```no_run @@ -92,7 +100,55 @@ impl Socks5MixnetClient { .await } - /// Get the nym address for this client, if it is available. The nym address is composed of the + /// Create a new client and connect to a network requester chosen per the + /// given [`NetworkRequester`]: auto-discovered ([`Any`](NetworkRequester::Any)), + /// country-restricted ([`InCountries`](NetworkRequester::InCountries)), or a + /// known address ([`Exact`](NetworkRequester::Exact)). + /// + /// The discovered requester enforces the Nym exit policy, so destinations + /// outside that policy are refused at the exit regardless of which + /// requester is selected. + /// + /// `bind` sets the local SOCKS5 listener address; pass `None` for the default + /// `127.0.0.1:1080`, or `Some(addr)` to move it (for example when 1080 is + /// already taken, or to run more than one client at once). + /// + /// # Examples + /// + /// ```no_run + /// use nym_sdk::mixnet::{NetworkRequester, Socks5MixnetClient}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// // Any requester, weighted by performance, on the default port: + /// let any = Socks5MixnetClient::connect_with(NetworkRequester::any(), None).await?; + /// + /// // Pinned to Switzerland or Germany, listening on 127.0.0.1:1081: + /// let pinned = Socks5MixnetClient::connect_with( + /// NetworkRequester::in_countries(["CH", "DE"])?, + /// Some("127.0.0.1:1081".parse()?), + /// ) + /// .await?; + /// Ok(()) + /// } + /// ``` + pub async fn connect_with( + requester: NetworkRequester, + bind: Option, + ) -> Result { + let provider = requester.resolve().await?; + let mut socks5_config = Socks5::new(provider.to_string()); + if let Some(addr) = bind { + socks5_config.bind_address = addr; + } + MixnetClientBuilder::new_ephemeral() + .socks5_config(socks5_config) + .build()? + .connect_to_mixnet_via_socks5() + .await + } + + /// Get the nym address of this client. The nym address is composed of the /// client identity, the client encryption key, and the gateway identity. pub fn nym_address(&self) -> &Recipient { &self.nym_address @@ -153,3 +209,230 @@ impl Socks5MixnetClient { } } } + +/// Which network requester (the mixnet exit that makes requests on the client's +/// behalf) a SOCKS5 client routes through. Three ways, increasing specificity. +#[derive(Debug, Clone, Default)] +pub enum NetworkRequester { + /// Auto-discover one from the current topology, weighted by performance. (default) + #[default] + Any, + /// Auto-discover, restricted to requesters physically located in one of + /// these ISO 3166 alpha-2 countries (e.g. `["CH", "DE"]`). + InCountries(Vec), + /// A specific requester address you already know. + Exact(Box), +} + +impl NetworkRequester { + /// Any requester, weighted by performance. + pub fn any() -> Self { + Self::Any + } + + /// Restrict discovery to the given ISO 3166 alpha-2 country codes. + /// Case-insensitive. Returns [`Error::InvalidCountryCode`] on the first + /// code that is not a valid alpha-2 code, or [`Error::NoCountriesSpecified`] + /// if the list is empty (use [`any`](Self::any) to accept any country). + #[allow(clippy::result_large_err)] + pub fn in_countries(codes: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let countries = codes + .into_iter() + .map(|c| { + Country::from_alpha2(c.as_ref()) + .map_err(|_| Error::InvalidCountryCode(c.as_ref().to_string())) + }) + .collect::, _>>()?; + + // An empty filter would resolve as "any country", silently ignoring the + // caller's intent to scope by location. Reject it so the mistake surfaces + // at the call site rather than as a surprising any-country pick. + if countries.is_empty() { + return Err(Error::NoCountriesSpecified); + } + + Ok(Self::InCountries(countries)) + } + + /// A specific requester by its Nym address. Returns + /// [`Error::InvalidRecipientAddress`] if the address does not parse. + #[allow(clippy::result_large_err)] + pub fn exact(address: impl AsRef) -> Result { + let recipient = address + .as_ref() + .parse() + .map_err(|_| Error::InvalidRecipientAddress(address.as_ref().to_string()))?; + Ok(Self::Exact(Box::new(recipient))) + } + + /// Resolve to a concrete requester address. `Exact` returns its address + /// directly; `Any` / `InCountries` query the mainnet directory and pick one + /// weighted by performance. + pub async fn resolve(&self) -> Result { + match self { + Self::Exact(addr) => Ok(**addr), + Self::Any => discovery::discover(&[]).await, + Self::InCountries(countries) => discovery::discover(countries).await, + } + } +} + +/// Directory crawl backing [`NetworkRequester`] auto-discovery. +/// +/// This mirrors the IPR discovery in [`crate::ip_packet_client::discovery`]: +/// exit gateways self-announce both an IPR and a network requester in the same +/// described-node payload (`NymNodeDataV2`), so the selection logic matches; only +/// the field we read differs (`network_requester` instead of `ip_packet_router`), +/// and there is no protocol-version gate. The node's physical location +/// (`auxiliary_details.location`) rides along in that same payload, so country +/// filtering needs no extra requests. +mod discovery { + use std::collections::HashMap; + + use celes::Country; + use nym_crypto::asymmetric::ed25519; + use nym_sphinx::addressing::clients::Recipient; + use nym_validator_client::nym_api::NymApiClientExt; + use rand::seq::SliceRandom; + use tracing::{debug, info, warn}; + + use crate::ip_packet_client::discovery::create_nym_api_client; + use crate::{Error, NymNetworkDetails}; + + /// Query the mainnet directory for network requesters and pick one weighted by + /// performance, optionally restricted to `countries` (empty slice = any). + pub(super) async fn discover(countries: &[Country]) -> Result { + let nym_api_urls = NymNetworkDetails::new_mainnet() + .nym_api_urls + .ok_or(Error::NoNymAPIUrl)?; + let client = create_nym_api_client(nym_api_urls)?; + get_best_network_requester_in(client, countries).await + } + + /// A network requester exit gateway and the metadata the directory reports for it. + struct NetworkRequesterWithPerformance { + address: Recipient, + identity: ed25519::PublicKey, + performance: u8, + /// Physical location the operator self-reported, if any. `None` means the + /// operator did not declare a location, not that the node is unlocated. + country: Option, + } + + /// Collect every exit gateway that advertises a network requester address, + /// paired with its performance score and self-reported country. + async fn retrieve_network_requesters_with_performance( + client: nym_http_api_client::Client, + ) -> Result, Error> { + let all_nodes = client + .get_all_described_nodes_v2() + .await? + .into_iter() + .map(|described| (described.ed25519_identity_key(), described)) + .collect::>(); + + let exit_gateways = client.get_all_basic_nodes_with_metadata().await?.nodes; + + let mut requesters = Vec::new(); + + for exit in exit_gateways { + let Some(node) = all_nodes.get(&exit.ed25519_identity_pubkey) else { + // The skimmed and described sets come from two separate API calls + // and can be momentarily out of sync, so a node present in one but + // not the other is expected churn rather than an error; log at debug. + debug!( + "{} has no described-node record; skipping", + exit.ed25519_identity_pubkey + ); + continue; + }; + + let Some(nr_info) = node.description.network_requester.clone() else { + continue; + }; + + match nr_info.address.parse() { + Ok(parsed_address) => requesters.push(NetworkRequesterWithPerformance { + address: parsed_address, + identity: exit.ed25519_identity_pubkey, + performance: exit.performance.round_to_integer(), + country: node.description.auxiliary_details.location, + }), + // A node that advertises a requester but with an unparseable address + // is malformed metadata. Drop it from the pool, but say which node + // and why rather than shrinking the pool silently. + Err(err) => warn!( + "{} advertises an unparseable network requester address {:?}: {err}; skipping", + exit.ed25519_identity_pubkey, nr_info.address + ), + } + } + + Ok(requesters) + } + + /// Select a network requester weighted by performance, restricted to the given + /// countries. An empty `countries` slice means any country is acceptable. + /// + /// Requesters that did not declare a location are excluded whenever a country + /// filter is active: an undeclared exit cannot be assumed to be in a requested + /// country. If the filter leaves no candidates, this returns + /// [`Error::NoGatewayInCountries`] rather than silently falling back. + async fn get_best_network_requester_in( + client: nym_http_api_client::Client, + countries: &[Country], + ) -> Result { + let requesters = retrieve_network_requesters_with_performance(client).await?; + let total = requesters.len(); + + let pool: Vec = if countries.is_empty() { + requesters + } else { + requesters + .into_iter() + .filter(|nr| match nr.country { + Some(c) => countries + .iter() + .any(|want| want.alpha2.eq_ignore_ascii_case(c.alpha2)), + None => false, + }) + .collect() + }; + + info!( + "Found {} network requesters ({} after country filter)", + total, + pool.len() + ); + + if pool.is_empty() { + return Err(if countries.is_empty() { + Error::NoGatewayAvailable + } else { + Error::NoGatewayInCountries + }); + } + + // Weight by performance. If every candidate scored zero (e.g. a low score + // rounded down to 0), fall back to a uniform pick rather than failing as if + // no requester existed. The pool is non-empty here. + let mut rng = rand::thread_rng(); + let selected = pool + .choose_weighted(&mut rng, |nr| nr.performance as f64) + .or_else(|_| pool.choose(&mut rng).ok_or(Error::NoGatewayAvailable))?; + + info!( + "Using network requester: {} (Gateway: {}, Country: {:?}, Performance: {:?})", + selected.address, + selected.identity, + selected.country.map(|c| c.alpha2), + selected.performance + ); + + Ok(selected.address) + } +}