Skip to main content

nym_sdk/mixnet/
socks5_discovery.rs

1//! Network requester selection and directory-based auto-discovery for the
2//! SOCKS5 client.
3
4use std::collections::HashMap;
5
6use celes::Country;
7use rand::seq::SliceRandom;
8use tracing::{debug, info, warn};
9
10use nym_crypto::asymmetric::ed25519;
11use nym_sphinx::addressing::clients::Recipient;
12use nym_validator_client::nym_api::NymApiClientExt;
13
14use crate::ip_packet_client::discovery::create_nym_api_client;
15use crate::{Error, NymNetworkDetails};
16
17/// Choose which network requester (the exit service that makes requests on the
18/// client's behalf) a SOCKS5 client routes through. Three ways, increasing specificity.
19#[derive(Debug, Clone, Default)]
20pub enum NetworkRequesterSelector {
21    /// Auto-discover one from the current topology, weighted by performance. (default)
22    #[default]
23    Any,
24    /// Auto-discover, restricted to requesters physically located in one of
25    /// these ISO 3166 alpha-2 countries (e.g. `["CH", "DE"]`).
26    InCountries(Vec<Country>),
27    /// A specific requester address you already know.
28    Exact(Box<Recipient>),
29}
30
31impl NetworkRequesterSelector {
32    /// Any requester, weighted by performance.
33    pub fn any() -> Self {
34        Self::Any
35    }
36
37    /// Restrict discovery to the given ISO 3166 alpha-2 country codes.
38    /// Case-insensitive. Returns [`Error::InvalidCountryCode`] on the first
39    /// code that is not a valid alpha-2 code, or [`Error::NoCountriesSpecified`]
40    /// if the list is empty (use [`any`](Self::any) to accept any country).
41    #[allow(clippy::result_large_err)]
42    pub fn in_countries<I, S>(codes: I) -> Result<Self, Error>
43    where
44        I: IntoIterator<Item = S>,
45        S: AsRef<str>,
46    {
47        let countries = codes
48            .into_iter()
49            .map(|c| {
50                Country::from_alpha2(c.as_ref())
51                    .map_err(|_| Error::InvalidCountryCode(c.as_ref().to_string()))
52            })
53            .collect::<Result<Vec<_>, _>>()?;
54
55        // An empty filter would silently resolve as "any country". Reject it so
56        // the mistake surfaces here rather than as a surprising any-country pick.
57        if countries.is_empty() {
58            return Err(Error::NoCountriesSpecified);
59        }
60
61        Ok(Self::InCountries(countries))
62    }
63
64    /// A specific requester by its Nym address. Returns
65    /// [`Error::InvalidRecipientAddress`] if the address does not parse.
66    #[allow(clippy::result_large_err)]
67    pub fn exact(address: impl AsRef<str>) -> Result<Self, Error> {
68        let recipient = address
69            .as_ref()
70            .parse()
71            .map_err(|_| Error::InvalidRecipientAddress(address.as_ref().to_string()))?;
72        Ok(Self::Exact(Box::new(recipient)))
73    }
74
75    /// Resolve to a concrete requester address. `Exact` returns its address
76    /// directly; `Any` / `InCountries` query the mainnet directory and pick one
77    /// weighted by performance.
78    pub async fn resolve(&self) -> Result<Recipient, Error> {
79        match self {
80            Self::Exact(addr) => Ok(**addr),
81            Self::Any => discover(&[]).await,
82            // Variants are pub, so InCountries(vec![]) can bypass in_countries()'s
83            // guard; re-check here or the filter silently degrades to any-country.
84            Self::InCountries(countries) if countries.is_empty() => {
85                Err(Error::NoCountriesSpecified)
86            }
87            Self::InCountries(countries) => discover(countries).await,
88        }
89    }
90}
91
92/// Query the mainnet directory for network requesters and pick one weighted by
93/// performance, optionally restricted to `countries` (empty slice = any).
94///
95/// Mirrors the IPR discovery in [`crate::ip_packet_client::discovery`]: the same
96/// described-node payload carries both, so this reads `network_requester` where
97/// that reads `ip_packet_router`, and location rides along for country filtering.
98async fn discover(countries: &[Country]) -> Result<Recipient, Error> {
99    let nym_api_urls = NymNetworkDetails::new_mainnet()
100        .nym_api_urls
101        .ok_or(Error::NoNymAPIUrl)?;
102    let client = create_nym_api_client(nym_api_urls)?;
103    get_best_network_requester_in(client, countries).await
104}
105
106/// A network requester on an exit gateway and the metadata the directory reports for it.
107struct NetworkRequesterWithPerformance {
108    address: Recipient,
109    identity: ed25519::PublicKey,
110    performance: u8,
111    /// Physical location the operator self-reported, if any. `None` means the
112    /// operator did not declare one, not that the node has no location.
113    country: Option<Country>,
114}
115
116/// Collect every node advertising a network requester, with its performance
117/// score and self-reported country.
118async fn retrieve_network_requesters_with_performance(
119    client: nym_http_api_client::Client,
120) -> Result<Vec<NetworkRequesterWithPerformance>, Error> {
121    let all_nodes = client
122        .get_all_described_nodes_v2()
123        .await?
124        .into_iter()
125        .map(|described| (described.ed25519_identity_key(), described))
126        .collect::<HashMap<_, _>>();
127
128    let basic_nodes = client.get_all_basic_nodes_with_metadata().await?.nodes;
129
130    let mut requesters = Vec::new();
131
132    for node_meta in basic_nodes {
133        let Some(node) = all_nodes.get(&node_meta.ed25519_identity_pubkey) else {
134            // The described set is a scraped subset of the basic set, so a basic
135            // node may lack a described record (recently bonded, or unreachable
136            // for scraping). Common and not actionable, so debug not warn.
137            debug!(
138                "{} has no described-node record; skipping",
139                node_meta.ed25519_identity_pubkey
140            );
141            continue;
142        };
143
144        let Some(nr_info) = node.description.network_requester.clone() else {
145            continue;
146        };
147
148        match nr_info.address.parse() {
149            Ok(parsed_address) => requesters.push(NetworkRequesterWithPerformance {
150                address: parsed_address,
151                identity: node_meta.ed25519_identity_pubkey,
152                performance: node_meta.performance.round_to_integer(),
153                country: node.description.auxiliary_details.location,
154            }),
155            // A node advertising a requester with an unparseable address is
156            // malformed metadata. Drop it, but say which node and why rather
157            // than shrinking the pool silently.
158            Err(err) => warn!(
159                "{} advertises an unparseable network requester address {:?}: {err}; skipping",
160                node_meta.ed25519_identity_pubkey, nr_info.address
161            ),
162        }
163    }
164
165    Ok(requesters)
166}
167
168/// Select a network requester weighted by performance, restricted to `countries`
169/// (empty = any). Requesters with no declared location are excluded when a filter
170/// is active (an undeclared node can't be assumed to match). Returns
171/// [`Error::NoGatewayInCountries`] if the filter leaves none.
172async fn get_best_network_requester_in(
173    client: nym_http_api_client::Client,
174    countries: &[Country],
175) -> Result<Recipient, Error> {
176    let requesters = retrieve_network_requesters_with_performance(client).await?;
177    let total = requesters.len();
178
179    let pool: Vec<NetworkRequesterWithPerformance> = if countries.is_empty() {
180        requesters
181    } else {
182        requesters
183            .into_iter()
184            .filter(|nr| match nr.country {
185                Some(c) => countries
186                    .iter()
187                    .any(|want| want.alpha2.eq_ignore_ascii_case(c.alpha2)),
188                None => false,
189            })
190            .collect()
191    };
192
193    info!(
194        "Found {} network requesters ({} after country filter)",
195        total,
196        pool.len()
197    );
198
199    if pool.is_empty() {
200        return Err(if countries.is_empty() {
201            Error::NoGatewayAvailable
202        } else {
203            Error::NoGatewayInCountries
204        });
205    }
206
207    // Weight by performance. If every candidate rounds to 0, fall back to a
208    // uniform pick rather than failing. The pool is non-empty here.
209    let mut rng = rand::thread_rng();
210    let selected = pool
211        .choose_weighted(&mut rng, |nr| nr.performance as f64)
212        .or_else(|_| pool.choose(&mut rng).ok_or(Error::NoGatewayAvailable))?;
213
214    info!(
215        "Using network requester: {} (Gateway: {}, Country: {:?}, Performance: {:?})",
216        selected.address,
217        selected.identity,
218        selected.country.map(|c| c.alpha2),
219        selected.performance
220    );
221
222    Ok(selected.address)
223}