nym_topology/
node.rs

1// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use nym_api_requests::models::DeclaredRoles;
5use nym_api_requests::nym_nodes::SkimmedNode;
6use nym_crypto::asymmetric::{ed25519, x25519};
7use nym_mixnet_contract_common::NodeId;
8use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
9use nym_sphinx_types::Node as SphinxNode;
10use serde::{Deserialize, Serialize};
11use std::net::{IpAddr, SocketAddr};
12use thiserror::Error;
13
14pub use nym_mixnet_contract_common::LegacyMixLayer;
15
16#[derive(Error, Debug)]
17pub enum RoutingNodeError {
18    #[error("node {node_id} ('{identity}') has not provided any valid ip addresses")]
19    NoIpAddressesProvided { node_id: NodeId, identity: String },
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct EntryDetails {
24    // to allow client to choose ipv6 preference, if available
25    pub ip_addresses: Vec<IpAddr>,
26    pub clients_ws_port: u16,
27    pub hostname: Option<String>,
28    pub clients_wss_port: Option<u16>,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
32pub struct SupportedRoles {
33    pub mixnode: bool,
34    pub mixnet_entry: bool,
35    pub mixnet_exit: bool,
36}
37
38impl From<DeclaredRoles> for SupportedRoles {
39    fn from(value: DeclaredRoles) -> Self {
40        SupportedRoles {
41            mixnode: value.mixnode,
42            mixnet_entry: value.entry,
43            mixnet_exit: value.exit_nr && value.exit_ipr,
44        }
45    }
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct RoutingNode {
50    pub node_id: NodeId,
51
52    pub mix_host: SocketAddr,
53
54    pub entry: Option<EntryDetails>,
55    pub identity_key: ed25519::PublicKey,
56    pub sphinx_key: x25519::PublicKey,
57
58    pub supported_roles: SupportedRoles,
59}
60
61impl RoutingNode {
62    pub fn ws_entry_address_tls(&self) -> Option<String> {
63        let entry = self.entry.as_ref()?;
64        let hostname = entry.hostname.as_ref()?;
65        let wss_port = entry.clients_wss_port?;
66
67        Some(format!("wss://{hostname}:{wss_port}"))
68    }
69
70    pub fn ws_entry_address_no_tls(&self, prefer_ipv6: bool) -> Option<String> {
71        let entry = self.entry.as_ref()?;
72
73        if let Some(hostname) = entry.hostname.as_ref() {
74            return Some(format!("ws://{hostname}:{}", entry.clients_ws_port));
75        }
76
77        if prefer_ipv6 && let Some(ipv6) = entry.ip_addresses.iter().find(|ip| ip.is_ipv6()) {
78            return Some(format!("ws://{ipv6}:{}", entry.clients_ws_port));
79        }
80
81        let any_ip = entry.ip_addresses.first()?;
82        Some(format!("ws://{any_ip}:{}", entry.clients_ws_port))
83    }
84
85    pub fn ws_entry_address(&self, prefer_ipv6: bool) -> Option<String> {
86        if let Some(tls) = self.ws_entry_address_tls() {
87            return Some(tls);
88        }
89        self.ws_entry_address_no_tls(prefer_ipv6)
90    }
91
92    pub fn ws_entry_address_with_fallback(
93        &self,
94        prefer_ipv6: bool,
95        no_hostname: bool,
96    ) -> (Option<String>, Option<String>) {
97        let Some(entry) = &self.entry else {
98            return (None, None);
99        };
100
101        // Put hostname first if we want it
102        let maybe_hostname = if !no_hostname {
103            entry.hostname.clone()
104        } else {
105            None
106        };
107
108        // Put ipv6 first or keep them as is
109        let ips: Vec<&IpAddr> = if prefer_ipv6 {
110            entry
111                .ip_addresses
112                .iter()
113                .filter(|ip| ip.is_ipv6())
114                .chain(entry.ip_addresses.iter().filter(|ip| ip.is_ipv4()))
115                .collect()
116        } else {
117            entry.ip_addresses.iter().collect()
118        };
119
120        // chain everything and keep the top two as ws addresses
121        let ws_addresses: Vec<_> = maybe_hostname
122            .into_iter()
123            .chain(ips.into_iter().map(|ip| ip.to_string()))
124            .take(2)
125            .map(|host| format!("ws://{host}:{}", entry.clients_ws_port))
126            .collect();
127
128        (ws_addresses.first().cloned(), ws_addresses.get(1).cloned())
129    }
130
131    pub fn identity(&self) -> ed25519::PublicKey {
132        self.identity_key
133    }
134}
135
136impl<'a> From<&'a RoutingNode> for SphinxNode {
137    fn from(node: &'a RoutingNode) -> Self {
138        // SAFETY: this conversion is infallible as all versions of socket addresses have
139        // sufficiently small bytes representation to fit inside `NodeAddressBytes`
140        #[allow(clippy::unwrap_used)]
141        let node_address_bytes = NymNodeRoutingAddress::from(node.mix_host)
142            .try_into()
143            .unwrap();
144
145        SphinxNode::new(node_address_bytes, node.sphinx_key.into())
146    }
147}
148
149impl<'a> TryFrom<&'a SkimmedNode> for RoutingNode {
150    type Error = RoutingNodeError;
151
152    fn try_from(value: &'a SkimmedNode) -> Result<Self, Self::Error> {
153        // IF YOU EVER ADD "performance" TO RoutingNode,
154        // MAKE SURE TO UPDATE THE LAZY IMPLEMENTATION OF
155        // `impl NodeDescriptionTopologyExt for NymNodeDescription`!!!
156
157        let Some(first_ip) = value.ip_addresses.first() else {
158            return Err(RoutingNodeError::NoIpAddressesProvided {
159                node_id: value.node_id,
160                identity: value.ed25519_identity_pubkey.to_string(),
161            });
162        };
163
164        let entry = value.entry.as_ref().map(|entry| EntryDetails {
165            ip_addresses: value.ip_addresses.clone(),
166            clients_ws_port: entry.ws_port,
167            hostname: entry.hostname.clone(),
168            clients_wss_port: entry.wss_port,
169        });
170
171        Ok(RoutingNode {
172            node_id: value.node_id,
173            mix_host: SocketAddr::new(*first_ip, value.mix_port),
174            entry,
175            identity_key: value.ed25519_identity_pubkey,
176            sphinx_key: value.x25519_sphinx_pubkey,
177            supported_roles: value.supported_roles.into(),
178        })
179    }
180}