Skip to main content

nym_node_requests/api/
helpers.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(feature = "client")]
5pub use client_helpers::*;
6
7#[cfg(feature = "client")]
8mod client_helpers {
9    use crate::api::SignedHostInformation;
10    use crate::api::client::NymNodeApiClientExt;
11    use nym_http_api_client::UserAgent;
12    use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT;
13    use std::time::Duration;
14
15    /// Builder-style helper for obtaining a validated [`crate::api::Client`] for a nym-node.
16    ///
17    /// On top of the basic port-probing performed by [`try_get_valid_nym_node_api_client`],
18    /// this struct optionally:
19    /// - verifies that the node's self-reported ed25519 identity matches an expected value
20    ///   (e.g. the identity committed on-chain during bonding), and
21    /// - checks the cryptographic signature on the node's host information.
22    ///
23    /// Both checks require an extra HTTP round-trip to the node's `/host-information` endpoint
24    /// and are skipped when neither option is enabled.
25    #[derive(Debug)]
26    pub struct NymNodeApiClientRetriever {
27        /// Expected (base58-encoded) ed25519 identity of the node.
28        /// used to check against data retrieved from the host information
29        expected_identity: Option<String>,
30
31        /// Custom port to use when attempting to query the node.
32        custom_port: Option<u16>,
33
34        /// User agent to use when attempting to query the node.
35        user_agent: UserAgent,
36
37        /// Specify whether the signature on the host information should be verified.
38        verify_host_information: bool,
39    }
40
41    impl NymNodeApiClientRetriever {
42        /// Creates a new retriever with the given user agent.
43        /// All optional checks (identity verification, host information signature)
44        /// are disabled by default — use the builder methods to enable them.
45        pub fn new(user_agent: impl Into<UserAgent>) -> Self {
46            Self {
47                expected_identity: None,
48                custom_port: None,
49                user_agent: user_agent.into(),
50                verify_host_information: false,
51            }
52        }
53
54        /// If set, the node's self-reported ed25519 identity (from its `/host-information`
55        /// endpoint) will be compared against this value. A mismatch produces
56        /// [`crate::error::Error::MismatchedIdentity`].
57        #[must_use]
58        pub fn with_expected_identity(mut self, expected_identity: Option<String>) -> Self {
59            self.expected_identity = expected_identity;
60            self
61        }
62
63        /// Prepend `http://<host>:<port>` to the list of addresses probed during
64        /// [`get_client`](Self::get_client), so it is tried before the standard ports.
65        #[must_use]
66        pub fn with_custom_port(mut self, port: Option<u16>) -> Self {
67            self.custom_port = port;
68            self
69        }
70
71        /// Enable cryptographic verification of the node's host information signature.
72        /// When enabled, [`get_client`](Self::get_client) will return
73        /// [`crate::error::Error::MissignedHostInformation`] if the signature is invalid.
74        #[must_use]
75        pub fn with_verify_host_information(mut self) -> Self {
76            self.verify_host_information = true;
77            self
78        }
79
80        /// Probe the node's HTTP API, perform any configured verification, and return the
81        /// client together with the [`SignedHostInformation`] if it was fetched.
82        ///
83        /// The host information is only retrieved when identity verification or signature
84        /// checking is enabled. When neither is active, the returned
85        /// [`ApiClientWithHostInformation::host_information`] will be `None`.
86        pub async fn get_client(
87            self,
88            base_host: &str,
89            node_id: u32,
90        ) -> Result<ApiClientWithHostInformation, crate::error::Error> {
91            let base_client = try_get_valid_nym_node_api_client(
92                base_host,
93                node_id,
94                self.custom_port,
95                self.user_agent,
96            )
97            .await?;
98
99            // no need to retrieve host information if we don't have to perform any verification
100            if !self.verify_host_information && self.expected_identity.is_none() {
101                return Ok(base_client.into());
102            }
103
104            let host_info = retrieve_validated_host_information(
105                &base_client,
106                node_id,
107                &self.expected_identity,
108                self.verify_host_information,
109            )
110            .await?;
111
112            Ok(ApiClientWithHostInformation::from(base_client).with_host_information(host_info))
113        }
114    }
115
116    /// Fetch a node's [`SignedHostInformation`] and optionally validate it.
117    ///
118    /// This is the standalone equivalent of the checks performed inside
119    /// [`NymNodeApiClientRetriever::get_client`], useful when the caller already
120    /// holds a [`crate::api::Client`] and only needs the host information.
121    ///
122    /// When `expected_ed25519_identity` is `Some`, the node's self-reported identity
123    /// is compared against it — a mismatch produces [`crate::error::Error::MismatchedIdentity`].
124    /// When `verify_host_information` is `true`, the cryptographic signature on the
125    /// host information is checked — an invalid signature produces
126    /// [`crate::error::Error::MissignedHostInformation`].
127    pub async fn retrieve_validated_host_information(
128        client: &crate::api::Client,
129        node_id: u32,
130        expected_ed25519_identity: &Option<String>,
131        verify_host_information: bool,
132    ) -> Result<SignedHostInformation, crate::error::Error> {
133        let host_info = match client.get_host_information().await {
134            Ok(info) => info,
135            Err(err) => {
136                return Err(crate::error::Error::QueryFailure {
137                    host: client.current_url().to_string(),
138                    node_id,
139                    source: Box::new(err),
140                });
141            }
142        };
143
144        if let Some(expected_identity) = expected_ed25519_identity {
145            // check if the identity key matches the information provided during bonding
146            if expected_identity.as_str() != host_info.keys.ed25519_identity.to_base58_string() {
147                return Err(crate::error::Error::MismatchedIdentity {
148                    node_id,
149                    expected: expected_identity.clone(),
150                    got: host_info.keys.ed25519_identity.to_base58_string(),
151                });
152            }
153        }
154
155        // check if the host information has been signed with the node's key
156        if verify_host_information && !host_info.verify_host_information() {
157            return Err(crate::error::Error::MissignedHostInformation { node_id });
158        }
159
160        Ok(host_info)
161    }
162
163    /// A nym-node API client bundled with the node's [`SignedHostInformation`],
164    /// if it was retrieved during the connection/verification phase.
165    ///
166    /// This avoids a redundant second call to the `/host-information` endpoint
167    /// when the caller also needs the host information after obtaining the client.
168    pub struct ApiClientWithHostInformation {
169        pub client: crate::api::Client,
170        pub host_information: Option<SignedHostInformation>,
171    }
172
173    impl ApiClientWithHostInformation {
174        fn with_host_information(self, host_information: SignedHostInformation) -> Self {
175            Self {
176                host_information: Some(host_information),
177                ..self
178            }
179        }
180    }
181
182    impl From<crate::api::Client> for ApiClientWithHostInformation {
183        fn from(client: crate::api::Client) -> Self {
184            Self {
185                client,
186                host_information: None,
187            }
188        }
189    }
190
191    /// Probe a nym-node's HTTP API and return a connected [`crate::api::Client`].
192    ///
193    /// `base_host` is a hostname (e.g. `nymtech.net`) or IP address (e.g. `127.0.0.1`).
194    /// The function tries the following addresses in order, returning the first one whose
195    /// `/health` endpoint reports an "up" status:
196    ///
197    /// 1. `http://<host>:<custom_port>` (only when `custom_port` is `Some`)
198    /// 2. `http://<host>:8080` — the standard nym-node API port
199    /// 3. `https://<host>` — node behind an HTTPS reverse proxy (port 443)
200    /// 4. `http://<host>` — node behind an HTTP reverse proxy (port 80)
201    ///
202    /// This function is intended for infrastructure binaries (nym-api, network monitor, etc.),
203    /// not regular clients, which is why hickory DNS is explicitly disabled.
204    pub async fn try_get_valid_nym_node_api_client(
205        base_host: &str,
206        node_id: u32,
207        custom_port: Option<u16>,
208        user_agent: impl Into<UserAgent>,
209    ) -> Result<crate::api::Client, crate::error::Error> {
210        // first try the standard port in case the operator didn't put the node behind the proxy,
211        // then default https (443)
212        // finally default http (80)
213        let mut addresses_to_try = vec![
214            format!("http://{base_host}:{DEFAULT_NYM_NODE_HTTP_PORT}"), // 'standard' nym-node
215            format!("https://{base_host}"), // node behind https proxy (443)
216            format!("http://{base_host}"),  // node behind http proxy (80)
217        ];
218
219        // if a custom port was provided, try to connect to it first
220        if let Some(port) = custom_port {
221            addresses_to_try.insert(0, format!("http://{base_host}:{port}"));
222        }
223
224        let user_agent = user_agent.into();
225        for address in addresses_to_try {
226            // if provided base_host was malformed, there's no point in continuing
227            let client = match crate::api::Client::builder(address).and_then(|b| {
228                b.with_timeout(Duration::from_secs(5))
229                    .no_hickory_dns()
230                    .with_user_agent(user_agent.clone())
231                    .build()
232            }) {
233                Ok(client) => client,
234                Err(err) => {
235                    return Err(crate::error::Error::MalformedHost {
236                        host: base_host.to_string(),
237                        node_id,
238                        source: Box::new(err),
239                    });
240                }
241            };
242
243            if let Ok(health) = client.get_health().await
244                && health.status.is_up()
245            {
246                return Ok(client);
247            }
248        }
249
250        Err(crate::error::Error::NoHttpPortsAvailable {
251            host: base_host.to_string(),
252            node_id,
253        })
254    }
255}