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}