1use std::net::SocketAddr;
8use std::sync::Arc;
9
10use nym_api_requests::models::described::v2::NymNodeDescriptionV2;
11use nym_crypto::asymmetric::{ed25519, x25519};
12use nym_registration_client::RegistrationNymNode;
13use nym_registration_common::{NymNodeInformation, NymNodeLPInformation};
14use rand::seq::SliceRandom;
15
16use crate::dvpn::{DvpnDirectory, QuicBridge};
17use crate::error::SessionError;
18
19#[derive(Clone, Debug)]
21pub enum GatewaySpec {
22 Identity(ed25519::PublicKey),
24 Country(String),
26 Random,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum WgRole {
34 Entry,
36 Exit,
38}
39
40pub struct SelectedGateway {
42 pub node: RegistrationNymNode,
44 pub identity: ed25519::PublicKey,
46 pub country: Option<String>,
48 pub node_id: u32,
50 pub ip: std::net::IpAddr,
52 pub name: Option<String>,
54 pub quic: Option<QuicBridge>,
56}
57
58impl SelectedGateway {
59 pub fn info(&self) -> GatewayInfo {
61 GatewayInfo {
62 identity: self.identity,
63 node_id: self.node_id,
64 country: self.country.clone(),
65 ip: self.ip,
66 name: self.name.clone(),
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
73pub struct GatewayInfo {
74 pub identity: ed25519::PublicKey,
76 pub node_id: u32,
78 pub country: Option<String>,
80 pub ip: std::net::IpAddr,
82 pub name: Option<String>,
84}
85
86fn wg_capable(desc: &NymNodeDescriptionV2, _role: WgRole) -> bool {
93 let d = &desc.description;
94 d.wireguard.is_some() && d.lewes_protocol.is_some() && d.authenticator.is_some()
95}
96
97fn build_lp(
100 desc: &NymNodeDescriptionV2,
101 identity: &ed25519::PublicKey,
102 ip: std::net::IpAddr,
103) -> Result<Option<NymNodeLPInformation>, SessionError> {
104 let Some(lp) = desc.description.lewes_protocol.as_ref() else {
105 return Ok(None);
106 };
107
108 let malformed = |reason: &str| SessionError::MalformedGateway {
109 identity: identity.to_base58_string(),
110 reason: reason.to_string(),
111 };
112
113 if !lp.verify(identity) {
114 return Err(malformed("invalid LP signature"));
115 }
116
117 let version: semver::Version = desc
118 .description
119 .build_information
120 .build_version
121 .parse()
122 .map_err(|_| malformed("unparseable build version"))?;
123 let ciphersuite = nym_kkt_ciphersuite::Ciphersuite::from_node_version(version)
124 .ok_or_else(|| malformed("no valid ciphersuite for node version"))?;
125 let expected_kem_key_hashes = lp
126 .content
127 .kem_keys()
128 .map_err(|_| malformed("malformed LP KEM key digests"))?;
129
130 Ok(Some(NymNodeLPInformation {
131 address: SocketAddr::new(ip, lp.content.control_port),
132 expected_kem_key_hashes,
133 x25519: lp.content.x25519,
134 ciphersuite,
135 lp_protocol_version: nym_lp_data::packet::version::CURRENT,
137 }))
138}
139
140fn build_node(desc: &NymNodeDescriptionV2) -> Result<SelectedGateway, SessionError> {
143 let identity = desc.ed25519_identity_key();
144 let ip = desc
145 .description
146 .host_information
147 .ip_address
148 .first()
149 .copied()
150 .ok_or_else(|| SessionError::MalformedGateway {
151 identity: identity.to_base58_string(),
152 reason: "no advertised IP address".to_string(),
153 })?;
154
155 let lp_data = build_lp(desc, &identity, ip)?;
156 let authenticator_address = desc
157 .description
158 .authenticator
159 .as_ref()
160 .and_then(|a| a.address.parse().ok());
161 let version = nym_authenticator_requests::AuthenticatorVersion::from(
162 desc.description.build_information.build_version.as_str(),
163 );
164 let country = desc
165 .description
166 .auxiliary_details
167 .location
168 .as_ref()
169 .map(|c| c.alpha2.to_string());
170
171 let node = NymNodeInformation {
172 identity,
173 ip_address: ip,
174 ipr_address: None,
175 authenticator_address,
176 lp_data,
177 version,
178 };
179 let keys = Arc::new(x25519::KeyPair::new(&mut rand::thread_rng()));
181
182 Ok(SelectedGateway {
183 node: RegistrationNymNode { node, keys },
184 identity,
185 country,
186 node_id: desc.node_id,
187 ip,
188 name: None,
189 quic: None,
190 })
191}
192
193fn build_and_enrich(
195 desc: &NymNodeDescriptionV2,
196 directory: Option<&DvpnDirectory>,
197) -> Result<SelectedGateway, SessionError> {
198 let mut selected = build_node(desc)?;
199 if let Some(entry) = directory.and_then(|d| d.entry(&selected.identity.to_base58_string())) {
200 selected.name = entry.name.clone();
201 selected.quic = entry.quic.clone();
202 if selected.country.is_none() {
204 selected.country = entry.country.clone();
205 }
206 }
207 Ok(selected)
208}
209
210fn quic_ok(
212 directory: Option<&DvpnDirectory>,
213 require_quic: bool,
214 identity: &ed25519::PublicKey,
215) -> bool {
216 !require_quic || directory.is_some_and(|d| d.has_quic(&identity.to_base58_string()))
217}
218
219pub(crate) fn select(
226 nodes: &[NymNodeDescriptionV2],
227 spec: &GatewaySpec,
228 role: WgRole,
229 directory: Option<&DvpnDirectory>,
230 require_quic: bool,
231 exclude: Option<&ed25519::PublicKey>,
232) -> Result<SelectedGateway, SessionError> {
233 let excluded = |id: &ed25519::PublicKey| exclude == Some(id);
234 match spec {
235 GatewaySpec::Identity(id) => {
236 if excluded(id) {
237 return Err(SessionError::SameGatewaySelected(id.to_base58_string()));
238 }
239 let desc = nodes
240 .iter()
241 .find(|n| &n.ed25519_identity_key() == id)
242 .ok_or_else(|| SessionError::GatewayNotFound(id.to_base58_string()))?;
243 if !wg_capable(desc, role) {
244 return Err(SessionError::NoWireguardGateway);
245 }
246 if !quic_ok(directory, require_quic, id) {
247 return Err(SessionError::NoQuicGateway {
248 spec: id.to_base58_string(),
249 });
250 }
251 build_and_enrich(desc, directory)
252 }
253 GatewaySpec::Country(cc) => {
254 let candidates: Vec<&NymNodeDescriptionV2> = nodes
255 .iter()
256 .filter(|n| {
257 let id = n.ed25519_identity_key();
258 !excluded(&id)
259 && wg_capable(n, role)
260 && n.description
261 .auxiliary_details
262 .location
263 .as_ref()
264 .is_some_and(|c| c.alpha2.eq_ignore_ascii_case(cc))
265 && quic_ok(directory, require_quic, &id)
266 })
267 .collect();
268 let desc = candidates.choose(&mut rand::thread_rng()).ok_or_else(|| {
269 if require_quic {
270 SessionError::NoQuicGateway {
271 spec: format!("country {cc}"),
272 }
273 } else {
274 SessionError::NoCountryMatch(cc.clone())
275 }
276 })?;
277 build_and_enrich(desc, directory)
278 }
279 GatewaySpec::Random => {
280 let candidates: Vec<&NymNodeDescriptionV2> = nodes
281 .iter()
282 .filter(|n| {
283 let id = n.ed25519_identity_key();
284 !excluded(&id) && wg_capable(n, role) && quic_ok(directory, require_quic, &id)
285 })
286 .collect();
287 let desc = candidates.choose(&mut rand::thread_rng()).ok_or_else(|| {
288 if require_quic {
289 SessionError::NoQuicGateway {
290 spec: "random".to_string(),
291 }
292 } else {
293 SessionError::NoWireguardGateway
294 }
295 })?;
296 build_and_enrich(desc, directory)
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
310
311 fn random_identity() -> ed25519::PublicKey {
312 *ed25519::KeyPair::new(&mut rand::thread_rng()).public_key()
313 }
314
315 #[test]
316 fn identity_not_found_over_empty_set() {
317 let id = random_identity();
318 let err = select(
319 &[],
320 &GatewaySpec::Identity(id),
321 WgRole::Entry,
322 None,
323 false,
324 None,
325 )
326 .err()
327 .expect("expected selection error");
328 match err {
329 SessionError::GatewayNotFound(s) => assert_eq!(s, id.to_base58_string()),
330 other => panic!("expected GatewayNotFound, got {other:?}"),
331 }
332 }
333
334 #[test]
335 fn excluded_identity_is_rejected() {
336 let id =
339 ed25519::PublicKey::from_base58_string("Gejc2CnSRFUxK6519ewmWM66ytDZbbuXytwLUgytCQUD")
340 .unwrap();
341 let err = select(
342 &[],
343 &GatewaySpec::Identity(id),
344 WgRole::Exit,
345 None,
346 false,
347 Some(&id),
348 )
349 .err()
350 .expect("expected selection error");
351 match err {
352 SessionError::SameGatewaySelected(s) => assert_eq!(s, id.to_base58_string()),
353 other => panic!("expected SameGatewaySelected, got {other:?}"),
354 }
355 }
356
357 #[test]
358 fn country_no_match_over_empty_set() {
359 let err = select(
360 &[],
361 &GatewaySpec::Country("CH".into()),
362 WgRole::Exit,
363 None,
364 false,
365 None,
366 )
367 .err()
368 .expect("expected selection error");
369 match err {
370 SessionError::NoCountryMatch(cc) => assert_eq!(cc, "CH"),
371 other => panic!("expected NoCountryMatch, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn random_no_gateway_over_empty_set() {
377 let err = select(&[], &GatewaySpec::Random, WgRole::Entry, None, false, None)
378 .err()
379 .expect("expected selection error");
380 assert!(matches!(err, SessionError::NoWireguardGateway));
381 }
382
383 #[test]
384 fn require_quic_without_directory_fails() {
385 let err = select(&[], &GatewaySpec::Random, WgRole::Entry, None, true, None)
387 .err()
388 .expect("expected selection error");
389 assert!(matches!(err, SessionError::NoQuicGateway { .. }));
390 }
391
392 #[test]
393 fn role_is_copy_and_comparable() {
394 let r = WgRole::Entry;
395 let r2 = r; assert_eq!(r, r2);
397 assert_ne!(WgRole::Entry, WgRole::Exit);
398 }
399
400 #[test]
401 fn error_messages_are_descriptive() {
402 assert!(SessionError::NoWireguardGateway
403 .to_string()
404 .contains("WireGuard-capable"));
405 assert!(SessionError::NoCountryMatch("DE".into())
406 .to_string()
407 .contains("DE"));
408 assert_eq!(
409 SessionError::Cancelled.to_string(),
410 "session setup was cancelled"
411 );
412 }
413}