Compare commits

..

13 Commits

Author SHA1 Message Date
jmwample b90a75266f place-holder attempting compatibility 2025-12-05 17:09:43 -07:00
jmwample 26b537e256 keep details provided by topology instead of just a url 2025-12-05 13:39:34 -07:00
Jack Wampler 17b9fa4dd5 DNS resilience patch (#6267)
* shared resolver static init, ipv4 only by default, nameserver list

* add fn to run a trial resolution with each nameserver and log results
2025-12-05 09:17:43 -07:00
benedettadavico 22793bc45e update changelog 2025-11-25 15:16:42 +01:00
Simon Wicky 37f3ef58a3 [bugfix] Tunnel not waiting on MixnetClient to shut down cleanly (#6225)
* return the handlefor a clean shutdown

* cargo lock
2025-11-21 16:39:12 +01:00
benedettadavico 1a4d64a0e5 bump versions 2025-11-14 11:23:47 +01:00
benedetta davico 4dcc568ec2 Merge pull request #6204 from nymtech/master
merging master to develop to maintain sync
2025-11-14 11:21:13 +01:00
benedetta davico 468835e3a2 Merge pull request #6199 from nymtech/release/2025.20-leerdammer
Release/2025.20 leerdammer
2025-11-14 10:36:23 +01:00
benedetta davico 28a866e26d Merge pull request #6198 from nymtech/release/2025.20-leerdammer
Release/2025.20 leerdammer
2025-11-14 10:36:11 +01:00
Jędrzej Stuczyński 350d244032 bugfix: fix credential proxy upgrade mode attestation url arg (#6202)
this includes bringing over changes introduced in #6174
2025-11-14 08:19:21 +00:00
Jack Wampler 17ca000782 HTTP API resilience enable & domain rotation conditions (#6200)
* http url fallback conditions

* include changes and tests for fronted

* Allow for explicit DNS error Handling in HTTP client  (#6201)

when sending http reqs add manual DNS so we can handle errors directly

* Address PR nits

---------

Co-authored-by: durch <durch@users.noreply.github.com>
2025-11-14 08:59:36 +01:00
benedettadavico babf113fe5 update changelog 2025-11-12 08:39:48 +01:00
benedettadavico 0d7487f530 bump versions 2025-10-31 13:24:27 +01:00
39 changed files with 962 additions and 473 deletions
+54
View File
@@ -4,6 +4,60 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2025.21-mozzarella] (2025-11-25)
- [bugfix] Tunnel not waiting on MixnetClient to shut down cleanly ([#6225])
- bugfix: fix credential proxy upgrade mode attestation url arg ([#6202])
- HTTP API resilience enable & domain rotation conditions ([#6200])
- Remove debug feature from http-macro spec in gateway probe ([#6195])
- DNS relibility and troubleshooting ([#6179])
- [bugfix] Distinguish authenticator errors by credential spent ([#6176])
- Typescript SDK 1.4.1 ([#6146])
- Enable URL rotation and retries for mixnet gateway init ([#6126])
- Feature/credential proxy jwt ([#5957])
[#6225]: https://github.com/nymtech/nym/pull/6225
[#6202]: https://github.com/nymtech/nym/pull/6202
[#6200]: https://github.com/nymtech/nym/pull/6200
[#6195]: https://github.com/nymtech/nym/pull/6195
[#6179]: https://github.com/nymtech/nym/pull/6179
[#6176]: https://github.com/nymtech/nym/pull/6176
[#6146]: https://github.com/nymtech/nym/pull/6146
[#6126]: https://github.com/nymtech/nym/pull/6126
[#5957]: https://github.com/nymtech/nym/pull/5957
## [2025.20-leerdammer] (2025-11-12)
- Max/tweak ts sdk actions ([#6185])
- chore: resolve clippy 1.91 warnings ([#6168])
- [chore] Remove unused dependencies ([#6151])
- Use typed-builder for registration client builder config ([#6150])
- tommy is too quick ([#6149])
- configurable mixnet client startup timeout ([#6148])
- [Feature/operators]: QUIC bridge deployment script v2 ([#6145])
- Bugfix: Add circuit breaker ([#6143])
- bugfix: update internal owner address in transferred share ([#6139])
- Update quic_bridge_deployment.sh for IPv4 and .deb package ([#6138])
- feat: expose more explicit new_with_fronted_urls builder for http API client ([#6136])
- bugfix: update stored epoch share when changing ownership ([#6135])
- Domain fronting ([#6134])
- bugfix: update stored epoch share when changing announce address ([#6131])
[#6185]: https://github.com/nymtech/nym/pull/6185
[#6168]: https://github.com/nymtech/nym/pull/6168
[#6151]: https://github.com/nymtech/nym/pull/6151
[#6150]: https://github.com/nymtech/nym/pull/6150
[#6149]: https://github.com/nymtech/nym/pull/6149
[#6148]: https://github.com/nymtech/nym/pull/6148
[#6145]: https://github.com/nymtech/nym/pull/6145
[#6143]: https://github.com/nymtech/nym/pull/6143
[#6139]: https://github.com/nymtech/nym/pull/6139
[#6138]: https://github.com/nymtech/nym/pull/6138
[#6136]: https://github.com/nymtech/nym/pull/6136
[#6135]: https://github.com/nymtech/nym/pull/6135
[#6134]: https://github.com/nymtech/nym/pull/6134
[#6131]: https://github.com/nymtech/nym/pull/6131
## [2025.19-kase] (2025-10-30)
- update ns agent workflow ([#6154])
Generated
+11 -9
View File
@@ -4825,7 +4825,7 @@ dependencies = [
[[package]]
name = "nym-api"
version = "1.1.68"
version = "1.1.70"
dependencies = [
"anyhow",
"async-trait",
@@ -5051,7 +5051,7 @@ dependencies = [
[[package]]
name = "nym-cli"
version = "1.1.65"
version = "1.1.67"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5134,7 +5134,7 @@ dependencies = [
[[package]]
name = "nym-client"
version = "1.1.65"
version = "1.1.67"
dependencies = [
"bs58",
"clap",
@@ -5254,7 +5254,9 @@ dependencies = [
"cosmrs",
"nym-crypto",
"nym-gateway-requests",
"nym-topology",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.12",
"time",
@@ -5443,7 +5445,7 @@ dependencies = [
[[package]]
name = "nym-credential-proxy"
version = "0.3.0-test"
version = "0.3.0"
dependencies = [
"anyhow",
"axum",
@@ -5866,12 +5868,12 @@ dependencies = [
"nym-credentials-interface",
"nym-crypto",
"nym-gateway-requests",
"nym-http-api-client",
"nym-network-defaults",
"nym-pemstore",
"nym-sphinx",
"nym-statistics-common",
"nym-task",
"nym-topology",
"nym-validator-client",
"rand 0.8.5",
"serde",
@@ -6363,7 +6365,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.66"
version = "1.1.68"
dependencies = [
"addr",
"anyhow",
@@ -6413,7 +6415,7 @@ dependencies = [
[[package]]
name = "nym-node"
version = "1.20.0"
version = "1.22.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -6940,7 +6942,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.65"
version = "1.1.67"
dependencies = [
"bs58",
"clap",
@@ -7681,7 +7683,7 @@ dependencies = [
[[package]]
name = "nymvisor"
version = "0.1.30"
version = "0.1.32"
dependencies = [
"anyhow",
"bytes",
+1 -1
View File
@@ -264,7 +264,7 @@ generic-array = "0.14.7"
getrandom = "0.2.10"
handlebars = "3.5.5"
hex = "0.4.3"
hickory-resolver = "0.25"
hickory-resolver = "0.25.2"
hkdf = "0.12.3"
hmac = "0.12.1"
http = "1"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-client"
version = "1.1.65"
version = "1.1.67"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
description = "Implementation of the Nym Client"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-socks5-client"
version = "1.1.65"
version = "1.1.67"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
edition = "2021"
@@ -11,6 +11,7 @@ rust-version.workspace = true
async-trait.workspace = true
cosmrs.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
time.workspace = true
tokio = { workspace = true, features = ["sync"] }
@@ -20,6 +21,7 @@ zeroize = { workspace = true, features = ["zeroize_derive"] }
nym-crypto = { path = "../../crypto", features = ["asymmetric"] }
nym-gateway-requests = { path = "../../gateway-requests" }
nym-topology = { path = "../../topology", features = ["persistence"] }
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.sqlx]
workspace = true
@@ -48,6 +48,6 @@ pub enum BadGateway {
raw_listener: String,
#[source]
source: url::ParseError,
source: serde_json::Error,
},
}
@@ -5,13 +5,13 @@ use crate::BadGateway;
use cosmrs::AccountId;
use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey};
use nym_topology::EntryDetails;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use time::OffsetDateTime;
use url::Url;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const REMOTE_GATEWAY_TYPE: &str = "remote";
@@ -67,7 +67,7 @@ impl GatewayDetails {
gateway_id: ed25519::PublicKey,
shared_key: Arc<SharedGatewayKey>,
gateway_owner_address: Option<AccountId>,
gateway_listener: Url,
gateway_listener: EntryDetails,
) -> Self {
GatewayDetails::Remote(RemoteGatewayDetails {
gateway_id,
@@ -164,8 +164,7 @@ pub struct RegisteredGateway {
pub gateway_type: GatewayType,
}
#[derive(Debug, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
#[derive(Debug, Serialize, Deserialize)]
pub struct RawRemoteGatewayDetails {
pub gateway_id_bs58: String,
pub derived_aes128_ctr_blake3_hmac_keys_bs58: Option<String>,
@@ -174,6 +173,16 @@ pub struct RawRemoteGatewayDetails {
pub gateway_listener: String,
}
impl Zeroize for RawRemoteGatewayDetails {
fn zeroize(&mut self) {
self.gateway_id_bs58.zeroize();
self.derived_aes128_ctr_blake3_hmac_keys_bs58.zeroize();
self.derived_aes256_gcm_siv_key.zeroize();
self.gateway_owner_address.zeroize();
}
}
impl ZeroizeOnDrop for RawRemoteGatewayDetails {}
impl TryFrom<RawRemoteGatewayDetails> for RemoteGatewayDetails {
type Error = BadGateway;
@@ -230,7 +239,7 @@ impl TryFrom<RawRemoteGatewayDetails> for RemoteGatewayDetails {
})
.transpose()?;
let gateway_listener = Url::parse(&value.gateway_listener).map_err(|source| {
let gateway_listener = serde_json::from_str(&value.gateway_listener).map_err(|source| {
BadGateway::MalformedListener {
gateway_id: value.gateway_id_bs58.clone(),
raw_listener: value.gateway_listener.clone(),
@@ -255,12 +264,16 @@ impl<'a> From<&'a RemoteGatewayDetails> for RawRemoteGatewayDetails {
SharedGatewayKey::Legacy(key) => (Some(key.to_base58_string()), None),
};
#[allow(clippy::expect_used)]
let gateway_listener = serde_json::to_string(&value.gateway_listener)
.expect("serialize for gateway details should never fail");
RawRemoteGatewayDetails {
gateway_id_bs58: value.gateway_id.to_base58_string(),
derived_aes128_ctr_blake3_hmac_keys_bs58,
derived_aes256_gcm_siv_key,
gateway_owner_address: value.gateway_owner_address.as_ref().map(|o| o.to_string()),
gateway_listener: value.gateway_listener.to_string(),
gateway_listener,
}
}
}
@@ -273,7 +286,7 @@ pub struct RemoteGatewayDetails {
pub gateway_owner_address: Option<AccountId>,
pub gateway_listener: Url,
pub gateway_listener: EntryDetails,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+2 -2
View File
@@ -2,10 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::asymmetric::ed25519;
use nym_topology::EntryDetails;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use time::OffsetDateTime;
use url::Url;
#[derive(Serialize, Deserialize)]
pub struct GatewayInfo {
@@ -14,7 +14,7 @@ pub struct GatewayInfo {
pub active: bool,
pub typ: String,
pub endpoint: Option<Url>,
pub endpoint: Option<EntryDetails>,
}
impl Display for GatewayInfo {
@@ -561,7 +561,7 @@ where
.gateway_owner_address
.as_ref()
.map(|o| o.to_string()),
details.gateway_listener.to_string(),
details.gateway_listener,
);
GatewayClient::new(
GatewayClientConfig::new_default()
@@ -436,7 +436,7 @@ where
}
}
if let Some(ref mut next_delay) = &mut self.next_delay {
if let Some(next_delay) = &mut self.next_delay {
// it is not yet time to return a message
if next_delay.as_mut().poll(cx).is_pending() {
return Poll::Pending;
+2 -6
View File
@@ -40,12 +40,8 @@ pub enum ClientCoreError {
#[error("no gateway with id: {0}")]
NoGatewayWithId(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
#[error("Invalid Endpoint: {0}")]
InvalidEndpoint(String),
#[error("no gateways on network")]
NoGatewaysOnNetwork,
+9 -9
View File
@@ -21,7 +21,7 @@ use url::Url;
#[cfg(not(target_arch = "wasm32"))]
use crate::init::websockets::connect_async;
use nym_topology::NodeId;
use nym_topology::{EntryDetails, NodeId};
#[cfg(not(target_arch = "wasm32"))]
use tokio::net::TcpStream;
#[cfg(not(target_arch = "wasm32"))]
@@ -55,7 +55,7 @@ const PING_TIMEOUT: Duration = Duration::from_millis(1000);
pub trait ConnectableGateway {
fn node_id(&self) -> NodeId;
fn identity(&self) -> ed25519::PublicKey;
fn clients_address(&self, prefer_ipv6: bool) -> Option<String>;
fn endpoint_details(&self) -> Option<EntryDetails>;
fn is_wss(&self) -> bool;
}
@@ -68,8 +68,8 @@ impl ConnectableGateway for RoutingNode {
self.identity_key
}
fn clients_address(&self, prefer_ipv6: bool) -> Option<String> {
self.ws_entry_address(prefer_ipv6)
fn endpoint_details(&self) -> Option<EntryDetails> {
self.entry.clone()
}
fn is_wss(&self) -> bool {
@@ -191,7 +191,7 @@ pub async fn gateways_for_init(
}
#[cfg(not(target_arch = "wasm32"))]
async fn connect(endpoint: &str) -> Result<WsConn, ClientCoreError> {
async fn connect(endpoint: &EntryDetails) -> Result<WsConn, ClientCoreError> {
match tokio::time::timeout(CONN_TIMEOUT, connect_async(endpoint)).await {
Err(_elapsed) => Err(ClientCoreError::GatewayConnectionTimeout),
Ok(Err(conn_failure)) => Err(conn_failure),
@@ -208,17 +208,17 @@ async fn measure_latency<G>(gateway: &G) -> Result<GatewayWithLatency<'_, G>, Cl
where
G: ConnectableGateway,
{
let Some(addr) = gateway.clients_address(false) else {
let Some(endpoint) = gateway.endpoint_details() else {
return Err(ClientCoreError::UnsupportedEntry {
id: gateway.node_id(),
identity: gateway.identity().to_string(),
});
};
trace!(
"establishing connection to {} ({addr})...",
"establishing connection to {} ({endpoint})...",
gateway.identity(),
);
let mut stream = connect(&addr).await?;
let mut stream = connect(&endpoint).await?;
let mut results = Vec::new();
for _ in 0..MEASUREMENTS {
@@ -379,7 +379,7 @@ pub(super) fn get_specified_gateway(
pub(super) async fn register_with_gateway(
gateway_id: ed25519::PublicKey,
gateway_listener: Url,
gateway_listener: EntryDetails,
our_identity: Arc<ed25519::KeyPair>,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
) -> Result<RegistrationResult, ClientCoreError> {
+20 -20
View File
@@ -14,6 +14,7 @@ use nym_gateway_client::client::InitGatewayClient;
use nym_gateway_requests::shared_key::SharedGatewayKey;
use nym_sphinx::addressing::clients::Recipient;
use nym_topology::node::RoutingNode;
use nym_topology::EntryDetails;
use nym_validator_client::client::IdentityKey;
use nym_validator_client::nyxd::AccountId;
use serde::Serialize;
@@ -22,7 +23,6 @@ use std::fmt::{Debug, Display};
use std::os::fd::RawFd;
use std::sync::Arc;
use time::OffsetDateTime;
use url::Url;
pub enum SelectedGateway {
Remote {
@@ -30,7 +30,7 @@ pub enum SelectedGateway {
gateway_owner_address: Option<AccountId>,
gateway_listener: Url,
gateway_listener: EntryDetails,
},
Custom {
gateway_id: ed25519::PublicKey,
@@ -46,25 +46,25 @@ impl SelectedGateway {
// for now, let's use 'old' behaviour, if you want to change it, you can pass it up the enum stack yourself : )
let prefer_ipv6 = false;
let gateway_listener = if must_use_tls {
node.ws_entry_address_tls()
.ok_or(ClientCoreError::UnsupportedWssProtocol {
gateway: node.identity_key.to_base58_string(),
})?
} else {
node.ws_entry_address(prefer_ipv6)
.ok_or(ClientCoreError::UnsupportedEntry {
id: node.node_id,
identity: node.identity_key.to_base58_string(),
})?
};
let gateway_listener = node.entry.ok_or(ClientCoreError::UnsupportedEntry {
id: node.node_id,
identity: node.identity_key.to_base58_string(),
})?;
let gateway_listener =
Url::parse(&gateway_listener).map_err(|source| ClientCoreError::MalformedListener {
gateway_id: node.identity_key.to_base58_string(),
raw_listener: gateway_listener,
source,
})?;
if must_use_tls
&& (gateway_listener.hostname.is_none() || gateway_listener.clients_wss_port.is_none())
{
return Err(ClientCoreError::UnsupportedWssProtocol {
gateway: node.identity_key.to_base58_string(),
});
}
if prefer_ipv6 && gateway_listener.ip_addresses.iter().all(|i| i.is_ipv4()) {
return Err(ClientCoreError::UnsupportedEntry {
id: node.node_id,
identity: node.identity_key.to_base58_string(),
});
}
Ok(SelectedGateway::Remote {
gateway_id: node.identity_key,
+41 -24
View File
@@ -1,43 +1,60 @@
use crate::error::ClientCoreError;
use nym_http_api_client::HickoryDnsResolver;
#[cfg(not(target_arch = "wasm32"))]
use nym_topology::EntryDetails;
use tokio::net::TcpStream;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use tungstenite::handshake::client::Response;
use url::{Host, Url};
use url::Url;
use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn connect_async(
endpoint: &str,
endpoint: &EntryDetails,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), ClientCoreError> {
let resolver = HickoryDnsResolver::default();
let uri = Url::parse(endpoint).map_err(|_| ClientCoreError::InvalidUrl(endpoint.to_owned()))?;
let uri = ws_entry_address(endpoint, false)
.ok_or(ClientCoreError::InvalidEndpoint(endpoint.to_string()))?;
let port: u16 = uri.port_or_known_default().unwrap_or(443);
let host = uri
.host()
.ok_or(ClientCoreError::InvalidUrl(endpoint.to_owned()))?;
// Get address for tcp connection, if a domain is provided use our preferred resolver rather than
// the default std resolve
let sock_addrs: Vec<SocketAddr> = match host {
Host::Ipv4(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Ipv6(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Domain(domain) => {
// Do a DNS lookup for the domain using our custom DNS resolver
resolver
.resolve_str(domain)
.await?
.map(|a| SocketAddr::new(a, port))
.collect()
}
};
let sock_addrs: Vec<SocketAddr> = endpoint
.ip_addresses
.iter()
.map(|addr| SocketAddr::new(addr.clone(), port))
.collect();
let stream = TcpStream::connect(&sock_addrs[..]).await?;
tokio_tungstenite::client_async_tls(endpoint, stream)
tokio_tungstenite::client_async_tls(uri.to_string(), stream)
.await
.map_err(Into::into)
}
pub fn ws_entry_address_tls(entry: &EntryDetails) -> Option<Url> {
let hostname = entry.hostname.as_ref()?;
let wss_port = entry.clients_wss_port?;
Url::parse(&format!("wss://{hostname}:{wss_port}")).ok()
}
pub fn ws_entry_address_no_tls(entry: &EntryDetails, prefer_ipv6: bool) -> Option<Url> {
if let Some(hostname) = entry.hostname.as_ref() {
return Url::parse(&format!("ws://{hostname}:{}", entry.clients_ws_port)).ok();
}
if prefer_ipv6 {
if let Some(ipv6) = entry.ip_addresses.iter().find(|ip| ip.is_ipv6()) {
return Url::parse(&format!("ws://{ipv6}:{}", entry.clients_ws_port)).ok();
}
}
let any_ip = entry.ip_addresses.first()?;
Url::parse(&format!("ws://{any_ip}:{}", entry.clients_ws_port)).ok()
}
pub fn ws_entry_address(entry: &EntryDetails, prefer_ipv6: bool) -> Option<Url> {
if let Some(tls) = ws_entry_address_tls(entry) {
return Some(tls);
}
ws_entry_address_no_tls(entry, prefer_ipv6)
}
+1 -1
View File
@@ -27,11 +27,11 @@ nym-credential-storage = { path = "../../credential-storage" }
nym-credentials-interface = { path = "../../credentials-interface" }
nym-crypto = { path = "../../crypto" }
nym-gateway-requests = { path = "../../gateway-requests" }
nym-http-api-client = { path = "../../http-api-client" }
nym-network-defaults = { path = "../../network-defaults" }
nym-sphinx = { path = "../../nymsphinx" }
nym-statistics-common = { path = "../../statistics" }
nym-pemstore = { path = "../../pemstore" }
nym-topology = { path = "../../topology", features = ["persistence"] }
nym-validator-client = { path = "../validator-client", default-features = false }
nym-task = { path = "../../task" }
serde = { workspace = true, features = ["derive"] }
@@ -28,13 +28,13 @@ use nym_sphinx::forwarding::packet::MixPacket;
use nym_statistics_common::clients::connection::ConnectionStatsEvent;
use nym_statistics_common::clients::ClientStatsSender;
use nym_task::ShutdownToken;
use nym_topology::EntryDetails;
use nym_validator_client::nyxd::contract_traits::DkgQueryClient;
use rand::rngs::OsRng;
use std::sync::Arc;
use tracing::instrument;
use tracing::*;
use tungstenite::protocol::Message;
use url::Url;
#[cfg(unix)]
use std::os::fd::RawFd;
@@ -62,14 +62,14 @@ pub struct GatewayConfig {
// currently a dead field
pub gateway_owner: Option<String>,
pub gateway_listener: String,
pub gateway_listener: EntryDetails,
}
impl GatewayConfig {
pub fn new(
gateway_identity: ed25519::PublicKey,
gateway_owner: Option<String>,
gateway_listener: String,
gateway_listener: EntryDetails,
) -> Self {
GatewayConfig {
gateway_identity,
@@ -92,7 +92,7 @@ pub struct GatewayClient<C, St = EphemeralCredentialStorage> {
authenticated: bool,
bandwidth: ClientBandwidth,
gateway_address: String,
gateway_address: EntryDetails,
gateway_identity: ed25519::PublicKey,
local_identity: Arc<ed25519::KeyPair>,
shared_key: Option<Arc<SharedGatewayKey>>,
@@ -201,7 +201,7 @@ impl<C, St> GatewayClient<C, St> {
#[cfg(not(target_arch = "wasm32"))]
pub async fn establish_connection(&mut self) -> Result<(), GatewayClientError> {
debug!(
"Attempting to establish connection to gateway at: {}",
"Attempting to establish connection to gateway at: {:?}",
self.gateway_address
);
let (ws_stream, _) = connect_async(
@@ -467,7 +467,9 @@ impl<C, St> GatewayClient<C, St> {
// right now there are no failure cases here, but this might change in the future
match gateway_protocol {
None => {
warn!("the gateway we're connected to has not specified its protocol version. It's probably running version < 1.1.X, but that's still fine for now. It will become a hard error in 1.2.0");
warn!(
"the gateway we're connected to has not specified its protocol version. It's probably running version < 1.1.X, but that's still fine for now. It will become a hard error in 1.2.0"
);
// note: in +1.2.0 we will have to return a hard error here
Ok(())
}
@@ -481,7 +483,9 @@ impl<C, St> GatewayClient<C, St> {
}
Some(_) => {
debug!("the gateway is using exactly the same (or older) protocol version as we are. We're good to continue!");
debug!(
"the gateway is using exactly the same (or older) protocol version as we are. We're good to continue!"
);
Ok(())
}
}
@@ -527,7 +531,7 @@ impl<C, St> GatewayClient<C, St> {
status,
} => (status, protocol_version),
ServerResponse::Error { message } => {
return Err(GatewayClientError::GatewayError(message))
return Err(GatewayClientError::GatewayError(message));
}
other => return Err(GatewayClientError::UnexpectedResponse { name: other.name() }),
};
@@ -589,7 +593,7 @@ impl<C, St> GatewayClient<C, St> {
{
ServerResponse::EncryptedResponse { ciphertext, nonce } => (ciphertext, nonce),
ServerResponse::Error { message } => {
return Err(GatewayClientError::GatewayError(message))
return Err(GatewayClientError::GatewayError(message));
}
other => return Err(GatewayClientError::UnexpectedResponse { name: other.name() }),
};
@@ -858,7 +862,9 @@ impl<C, St> GatewayClient<C, St> {
warn!("Not enough bandwidth. Trying to get more bandwidth, this might take a while");
if !self.cfg.bandwidth.require_tickets {
info!("The client is running in disabled credentials mode - attempting to claim bandwidth without a credential");
info!(
"The client is running in disabled credentials mode - attempting to claim bandwidth without a credential"
);
return self.try_claim_testnet_bandwidth().await;
}
@@ -896,7 +902,9 @@ impl<C, St> GatewayClient<C, St> {
Err(err) => {
error!("failed to claim ecash bandwidth with the gateway...: {err}");
if err.is_ticket_replay() {
warn!("this was due to our ticket being replayed! have you messed with the database file?")
warn!(
"this was due to our ticket being replayed! have you messed with the database file?"
)
} else {
// TODO: tracing span
info!("attempting to revert ticket withdrawal...");
@@ -1112,7 +1120,9 @@ impl<C, St> GatewayClient<C, St> {
self.cfg
.bandwidth
.ensure_above_cutoff(bandwidth_remaining)?;
info!("Claiming more bandwidth with existing credentials. Stop the process now if you don't want that to happen.");
info!(
"Claiming more bandwidth with existing credentials. Stop the process now if you don't want that to happen."
);
self.claim_bandwidth().await?;
}
Ok(())
@@ -1128,7 +1138,7 @@ pub struct InitOnly;
impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
// for initialisation we do not need credential storage. Though it's still a bit weird we have to set the generic...
pub fn new_init(
gateway_listener: Url,
gateway_listener: EntryDetails,
gateway_identity: ed25519::PublicKey,
local_identity: Arc<ed25519::KeyPair>,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
@@ -1147,7 +1157,7 @@ impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
cfg: GatewayClientConfig::default().with_disabled_credentials_mode(true),
authenticated: false,
bandwidth: ClientBandwidth::new_empty(),
gateway_address: gateway_listener.to_string(),
gateway_address: gateway_listener.clone(),
gateway_identity,
local_identity,
shared_key: None,
@@ -1,51 +1,37 @@
use crate::error::GatewayClientError;
use nym_http_api_client::HickoryDnsResolver;
#[cfg(not(target_arch = "wasm32"))]
use nym_topology::EntryDetails;
#[cfg(unix)]
use std::{
os::fd::{AsRawFd, RawFd},
sync::Arc,
};
use tokio::net::TcpSocket;
use tokio::net::TcpStream;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use tungstenite::handshake::client::Response;
use url::{Host, Url};
use url::Url;
use std::net::SocketAddr;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn connect_async(
endpoint: &str,
endpoint: &EntryDetails,
#[cfg(unix)] connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), GatewayClientError> {
use tokio::net::TcpSocket;
let resolver = HickoryDnsResolver::default();
let uri =
Url::parse(endpoint).map_err(|_| GatewayClientError::InvalidUrl(endpoint.to_owned()))?;
let uri = ws_entry_address(endpoint, false)
.ok_or(GatewayClientError::InvalidEndpoint(endpoint.to_string()))?;
let port: u16 = uri.port_or_known_default().unwrap_or(443);
let host = uri
.host()
.ok_or(GatewayClientError::InvalidUrl(endpoint.to_owned()))?;
// Get address for tcp connection, if a domain is provided use our preferred resolver rather than
// the default std resolve
let sock_addrs: Vec<SocketAddr> = match host {
Host::Ipv4(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Ipv6(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Domain(domain) => {
// Do a DNS lookup for the domain using our custom DNS resolver
resolver
.resolve_str(domain)
.await?
.map(|a| SocketAddr::new(a, port))
.collect()
}
};
let sock_addrs = endpoint
.ip_addresses
.iter()
.map(|addr| SocketAddr::new(*addr, port));
let uri_str = uri.to_string();
let mut stream = Err(GatewayClientError::NoEndpointForConnection {
address: endpoint.to_owned(),
address: uri_str.clone(),
});
for sock_addr in sock_addrs {
let socket = if sock_addr.is_ipv4() {
@@ -54,7 +40,7 @@ pub(crate) async fn connect_async(
TcpSocket::new_v6()
}
.map_err(|err| GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
address: uri_str.clone(),
source: Box::new(tungstenite::Error::from(err)),
})?;
@@ -70,7 +56,7 @@ pub(crate) async fn connect_async(
}
Err(err) => {
stream = Err(GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
address: uri_str.clone(),
source: Box::new(tungstenite::Error::from(err)),
});
continue;
@@ -78,10 +64,39 @@ pub(crate) async fn connect_async(
}
}
tokio_tungstenite::client_async_tls(endpoint, stream?)
tokio_tungstenite::client_async_tls(uri.clone(), stream?)
.await
.map_err(|error| GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
address: uri_str.clone(),
source: Box::new(error),
})
}
pub fn ws_entry_address_tls(entry: &EntryDetails) -> Option<Url> {
let hostname = entry.hostname.as_ref()?;
let wss_port = entry.clients_wss_port?;
Url::parse(&format!("wss://{hostname}:{wss_port}")).ok()
}
pub fn ws_entry_address_no_tls(entry: &EntryDetails, prefer_ipv6: bool) -> Option<Url> {
if let Some(hostname) = entry.hostname.as_ref() {
return Url::parse(&format!("ws://{hostname}:{}", entry.clients_ws_port)).ok();
}
if prefer_ipv6 {
if let Some(ipv6) = entry.ip_addresses.iter().find(|ip| ip.is_ipv6()) {
return Url::parse(&format!("ws://{ipv6}:{}", entry.clients_ws_port)).ok();
}
}
let any_ip = entry.ip_addresses.first()?;
Url::parse(&format!("ws://{any_ip}:{}", entry.clients_ws_port)).ok()
}
pub fn ws_entry_address(entry: &EntryDetails, prefer_ipv6: bool) -> Option<Url> {
if let Some(tls) = ws_entry_address_tls(entry) {
return Some(tls);
}
ws_entry_address_no_tls(entry, prefer_ipv6)
}
@@ -49,12 +49,8 @@ pub enum GatewayClientError {
#[error("no socket address for endpoint: {address}")]
NoEndpointForConnection { address: String },
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
#[error("Invalid Endpoint: {0}")]
InvalidEndpoint(String),
#[error("No shared key was provided or obtained")]
NoSharedKeyAvailable,
+8
View File
@@ -168,6 +168,14 @@ pub enum CredentialProxyError {
device_id: String,
credential_id: String,
},
#[error(
"the attestation check url has not been provided through either the CLI nor the default .env config"
)]
AttestationCheckUrlNotSet,
#[error("the provided attestation check url is malformed: {source}")]
MalformedAttestationCheckUrl { source: url::ParseError },
}
impl From<NymAPIError> for CredentialProxyError {
+1 -100
View File
@@ -27,8 +27,6 @@ pub struct QuorumStateChecker {
cancellation_token: CancellationToken,
check_interval: Duration,
quorum_state: QuorumState,
max_retries: u32,
retry_initial_delay: Duration,
}
impl QuorumStateChecker {
@@ -44,8 +42,6 @@ impl QuorumStateChecker {
quorum_state: QuorumState {
available: Arc::new(Default::default()),
},
max_retries: 3,
retry_initial_delay: Duration::from_secs(2),
};
// first check MUST succeed, otherwise we shouldn't start
@@ -60,102 +56,7 @@ impl QuorumStateChecker {
self.quorum_state.clone()
}
fn is_retryable_error(&self, err: &CredentialProxyError) -> bool {
let err_str = err.to_string().to_lowercase();
// Check for DNS-related errors
if err_str.contains("dns")
|| err_str.contains("lookup")
|| err_str.contains("name resolution")
|| err_str.contains("temporary failure")
|| err_str.contains("failed to lookup address")
{
return true;
}
// Check if it's a Tendermint RPC error (which could be DNS/timeout related)
if let CredentialProxyError::NyxdFailure { source: nyxd_err } = err {
let nyxd_err_str = nyxd_err.to_string().to_lowercase();
if nyxd_err_str.contains("tendermint rpc request failed") {
return true;
}
if nyxd_err.is_tendermint_response_timeout() {
return true;
}
}
false
}
async fn check_quorum_state(&self) -> Result<bool, CredentialProxyError> {
self.check_quorum_state_with_retry().await
}
async fn check_quorum_state_with_retry(&self) -> Result<bool, CredentialProxyError> {
let mut last_error_msg = None;
let delay = self.retry_initial_delay;
for attempt in 0..=self.max_retries {
match self.check_quorum_state_once().await {
Ok(result) => {
if attempt > 0 {
info!("quorum check succeeded after {} retry attempt(s)", attempt);
}
return Ok(result);
}
Err(err) => {
let err_msg = err.to_string();
// Check if this error is retryable
if !self.is_retryable_error(&err) {
return Err(err);
}
last_error_msg = Some(err_msg.clone());
if attempt >= self.max_retries {
break;
}
// Log the retry attempt
warn!(
"quorum check failed (attempt {}/{}): {}. Retrying in {:?}...",
attempt + 1,
self.max_retries + 1,
err_msg,
delay
);
// Wait before retrying with exponential backoff
tokio::time::sleep(delay).await;
}
}
}
// try one final time to get the actual error
match self.check_quorum_state_once().await {
Ok(result) => {
warn!(
"quorum check succeeded on final attempt after {} retries",
self.max_retries
);
Ok(result)
}
Err(err) => {
if let Some(error_msg) = last_error_msg {
error!(
"quorum check failed after {} retry attempts. Last error: {}",
self.max_retries + 1,
error_msg
);
}
Err(err)
}
}
}
async fn check_quorum_state_once(&self) -> Result<bool, CredentialProxyError> {
let client_guard = self.client.query_chain().await;
// split the operation as we only need to hold the reference to chain client for the first part
@@ -192,7 +93,7 @@ impl QuorumStateChecker {
break
}
_ = tokio::time::sleep(self.check_interval) => {
match self.check_quorum_state_with_retry().await {
match self.check_quorum_state().await {
Ok(available) => self.quorum_state.available.store(available, Ordering::SeqCst),
Err(err) => error!("failed to check current quorum state: {err}"),
}
+370 -129
View File
@@ -3,28 +3,41 @@
//! DNS resolver configuration for internal lookups.
//!
//! The resolver itself is the set combination of the google, cloudflare, and quad9 endpoints
//! supporting DoH and DoT.
//! The resolver itself is the set combination of the cloudflare, and quad9 endpoints supporting DoH
//! and DoT.
//!
//! This resolver supports a fallback mechanism where, should the DNS-over-TLS resolution fail, a
//! followup resolution will be done using the hosts configured default (e.g. `/etc/resolve.conf` on
//! linux). This is disabled by default and can be enabled using [`enable_system_fallback`].
//!
//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the
//! `hickory-resolver` crate
//!
//!
//! Note: The hickory DoH resolver can cause warning logs about H2 connection failure. This
//! indicates that the long lived https connection was closed by the remote peer and the resolver
//! will have to reconnect. It should not impact actual functionality.
//!
//! code ref: https://github.com/hickory-dns/hickory-dns/blob/06a8b1ce9bd9322d8e6accf857d30257e1274427/crates/proto/src/h2/h2_client_stream.rs#L534
//!
//! example log:
//!
//! ```txt
//! WARN /home/ubuntu/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hickory-proto-0.24.3/src/h2/h2_client_stream.rs:493: h2 connection failed: unexpected end of file
//! ```rust
//! use nym_http_api_client::HickoryDnsResolver;
//! # use nym_http_api_client::ResolveError;
//! # type Err = ResolveError;
//! # async fn run() -> Result<(), Err> {
//! let resolver = HickoryDnsResolver::default();
//! resolver.resolve_str("example.com").await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Fallbacks
//!
//! **System Resolver --** This resolver supports an optional fallback mechanism where, should the
//! DNS-over-TLS resolution fail, a followup resolution will be done using the hosts configured
//! default (e.g. `/etc/resolve.conf` on linux).
//!
//! This is disabled by default and can be enabled using `enable_system_fallback`.
//!
//! **Static Table --** There is also a second optional fallback mechanism that allows a static map
//! to be used as a last resort. This can help when DNS encounters errors due to blocked resolvers
//! or unknown conditions. This is enabled by default, and can be customized if building a new
//! resolver.
//!
//! ## IPv4 / IPv6
//!
//! By default the resolver uses only IPv4 nameservers, and is configured to do `A` lookups first,
//! and only do `AAAA` if no `A` record is available.
//!
//! ---
//!
//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the `hickory-resolver` crate
#![deny(missing_docs)]
use crate::ClientBuilder;
@@ -39,7 +52,7 @@ use std::{
use hickory_resolver::{
TokioResolver,
config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig},
config::{NameServerConfig, NameServerConfigGroup, ResolverConfig, ResolverOpts},
lookup_ip::LookupIpIntoIter,
name_server::TokioConnectionProvider,
};
@@ -49,7 +62,11 @@ use tracing::*;
mod constants;
mod static_resolver;
pub use static_resolver::*;
pub(crate) use static_resolver::*;
pub(crate) const DEFAULT_POSITIVE_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(1800);
pub(crate) const DEFAULT_OVERALL_LOOKUP_TIMEOUT: Duration = Duration::from_secs(6);
pub(crate) const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_secs(3);
impl ClientBuilder {
/// Override the DNS resolver implementation used by the underlying http client.
@@ -71,7 +88,10 @@ impl ClientBuilder {
// but tools like valgrind might report "memory leaks" as it isn't obvious this is intentional.
static SHARED_RESOLVER: LazyLock<HickoryDnsResolver> = LazyLock::new(|| {
tracing::debug!("Initializing shared DNS resolver");
HickoryDnsResolver::default()
HickoryDnsResolver {
use_shared: false, // prevent infinite recursion
..Default::default()
}
});
#[derive(Debug, thiserror::Error)]
@@ -88,6 +108,13 @@ pub enum ResolveError {
StaticLookupMiss,
}
impl ResolveError {
/// Returns true if the error is a timeout.
pub fn is_timeout(&self) -> bool {
matches!(self, ResolveError::Timeout)
}
}
/// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait.
///
/// Typical use involves instantiating using the `Default` implementation and then resolving using
@@ -104,7 +131,7 @@ pub struct HickoryDnsResolver {
state: Arc<OnceCell<TokioResolver>>,
fallback: Option<Arc<OnceCell<TokioResolver>>>,
static_base: Option<Arc<OnceCell<StaticResolver>>>,
dont_use_shared: bool,
use_shared: bool,
/// Overall timeout for dns lookup associated with any individual host resolution. For example,
/// use of retries, server_ordering_strategy, etc. ends absolutely if this timeout is reached.
overall_dns_timeout: Duration,
@@ -115,9 +142,9 @@ impl Default for HickoryDnsResolver {
Self {
state: Default::default(),
fallback: Default::default(),
static_base: Default::default(),
dont_use_shared: Default::default(),
overall_dns_timeout: Duration::from_secs(10),
static_base: Some(Default::default()),
use_shared: true,
overall_dns_timeout: DEFAULT_OVERALL_LOOKUP_TIMEOUT,
}
}
}
@@ -127,7 +154,7 @@ impl Resolve for HickoryDnsResolver {
let resolver = self.state.clone();
let maybe_fallback = self.fallback.clone();
let maybe_static = self.static_base.clone();
let independent = self.dont_use_shared;
let use_shared = self.use_shared;
let overall_dns_timeout = self.overall_dns_timeout;
Box::pin(async move {
resolve(
@@ -135,7 +162,7 @@ impl Resolve for HickoryDnsResolver {
resolver,
maybe_fallback,
maybe_static,
independent,
use_shared,
overall_dns_timeout,
)
.await
@@ -229,7 +256,7 @@ impl HickoryDnsResolver {
self.state.clone(),
self.fallback.clone(),
self.static_base.clone(),
self.dont_use_shared,
self.use_shared,
self.overall_dns_timeout,
)
.await
@@ -239,25 +266,25 @@ impl HickoryDnsResolver {
/// Create a (lazy-initialized) resolver that is not shared across threads.
pub fn thread_resolver() -> Self {
Self {
dont_use_shared: true,
use_shared: false,
..Default::default()
}
}
fn new_resolver(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
fn new_resolver(use_shared: bool) -> Result<TokioResolver, ResolveError> {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared {
if !use_shared {
new_resolver()
} else {
Ok(SHARED_RESOLVER.state.get_or_try_init(new_resolver)?.clone())
}
}
fn new_resolver_system(dont_use_shared: bool) -> Result<TokioResolver, ResolveError> {
fn new_resolver_system(use_shared: bool) -> Result<TokioResolver, ResolveError> {
// using a closure here is slightly gross, but this makes sure that if the
// lazy-init returns an error it can be handled by the client
if dont_use_shared || SHARED_RESOLVER.fallback.is_none() {
if !use_shared || SHARED_RESOLVER.fallback.is_none() {
new_resolver_system()
} else {
Ok(SHARED_RESOLVER
@@ -269,8 +296,8 @@ impl HickoryDnsResolver {
}
}
fn new_static_fallback(dont_use_shared: bool) -> StaticResolver {
if !dont_use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
fn new_static_fallback(use_shared: bool) -> StaticResolver {
if use_shared && let Some(ref shared_resolver) = SHARED_RESOLVER.static_base {
shared_resolver
.get_or_init(new_default_static_fallback)
.clone()
@@ -287,6 +314,11 @@ impl HickoryDnsResolver {
.as_ref()
.unwrap()
.get_or_try_init(new_resolver_system)?;
// IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
// if self.use_shared {
// SHARED_RESOLVER.enable_system_fallback()?;
// }
Ok(())
}
@@ -294,6 +326,11 @@ impl HickoryDnsResolver {
/// returned immediately
pub fn disable_system_fallback(&mut self) {
self.fallback = None;
// // IF THIS INSTANCE IS A FRONT FOR THE SHARED RESOLVER SHOULDN'T THIS FN ENABLE THE SYSTEM FALLBACK FOR THE SHARED RESOLVER TOO?
// if self.use_shared {
// SHARED_RESOLVER.fallback = None;
// }
}
/// Get the current map of hostname to address in use by the fallback static lookup if one
@@ -309,39 +346,122 @@ impl HickoryDnsResolver {
.expect("infallible assign");
self.static_base = Some(Arc::new(cell));
}
/// Successfully resolved addresses are cached for a minimum of 30 minutes
/// Individual lookup Timeouts are set to 3 seconds
/// Number of retries after lookup failure before giving up is set to (default) to 2
/// Lookup order is set to (default) A then AAAA
/// Number or parallel lookup is set to (default) 2
/// Nameserver selection uses the (default) EWMA statistics / performance based strategy
fn default_options() -> ResolverOpts {
let mut opts = ResolverOpts::default();
// Always cache successful responses for queries received by this resolver for 30 min minimum.
opts.positive_min_ttl = Some(DEFAULT_POSITIVE_LOOKUP_CACHE_TTL);
opts.timeout = DEFAULT_QUERY_TIMEOUT;
opts
}
/// Get the list of currently available nameserver configs.
pub fn all_configured_name_servers(&self) -> Vec<NameServerConfig> {
default_nameserver_group().to_vec()
}
/// Get the list of currently used nameserver configs.
pub fn active_name_servers(&self) -> Vec<NameServerConfig> {
if !self.use_shared {
return self
.state
.get()
.map(|r| r.config().name_servers().to_vec())
.unwrap_or(self.all_configured_name_servers());
}
SHARED_RESOLVER.active_name_servers()
}
/// Do a trial resolution using each nameserver individually to test which are working and which
/// fail to complete a lookup. This will always try the full set of default configured resolvers.
pub async fn trial_nameservers(&self) {
let nameservers = default_nameserver_group();
for (ns, result) in trial_nameservers_inner(&nameservers).await {
if let Err(e) = result {
warn!("trial {ns:?} errored: {e}");
} else {
info!("trial {ns:?} succeeded");
}
}
}
}
/// Create a new resolver with a custom DoT based configuration. The options are overridden to look
/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm.
///
/// Timeout Defaults to 5 seconds
/// Individual lookup Timeouts are set to 3 seconds
/// Number of retries after lookup failure before giving up Defaults to 2
/// Lookup order is set to (default) A then AAAA
///
/// Caches successfully resolved addresses for 30 minutes to prevent continual use of remote lookup.
/// This resolver is intended to be used for OUR API endpoints that do not rapidly rotate IPs.
fn new_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new configured resolver");
let name_servers = default_nameserver_group_ipv4_only();
let mut name_servers = NameServerConfigGroup::quad9_tls();
name_servers.merge(NameServerConfigGroup::quad9_https());
name_servers.merge(NameServerConfigGroup::cloudflare_tls());
name_servers.merge(NameServerConfigGroup::cloudflare_https());
configure_and_build_resolver(name_servers)
Ok(configure_and_build_resolver(name_servers))
}
fn configure_and_build_resolver(
name_servers: NameServerConfigGroup,
) -> Result<TokioResolver, ResolveError> {
fn configure_and_build_resolver<G>(name_servers: G) -> TokioResolver
where
G: Into<NameServerConfigGroup>,
{
let options = HickoryDnsResolver::default_options();
let name_servers: NameServerConfigGroup = name_servers.into();
info!("building new configured resolver");
debug!("configuring resolver with {options:?}, {name_servers:?}");
let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
let mut resolver_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
// Cache successful responses for queries received by this resolver for 30 min minimum.
resolver_builder.options_mut().positive_min_ttl = Some(Duration::from_secs(1800));
resolver_builder = resolver_builder.with_options(options);
Ok(resolver_builder.build())
resolver_builder.build()
}
fn filter_ipv4(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
nameservers
.as_ref()
.iter()
.filter(|ns| ns.socket_addr.is_ipv4())
.cloned()
.collect()
}
#[allow(unused)]
fn filter_ipv6(nameservers: impl AsRef<[NameServerConfig]>) -> Vec<NameServerConfig> {
nameservers
.as_ref()
.iter()
.filter(|ns| ns.socket_addr.is_ipv6())
.cloned()
.collect()
}
#[allow(unused)]
fn default_nameserver_group() -> NameServerConfigGroup {
let mut name_servers = NameServerConfigGroup::quad9_tls();
name_servers.merge(NameServerConfigGroup::quad9_https());
name_servers.merge(NameServerConfigGroup::cloudflare_tls());
name_servers.merge(NameServerConfigGroup::cloudflare_https());
name_servers
}
fn default_nameserver_group_ipv4_only() -> NameServerConfigGroup {
filter_ipv4(&default_nameserver_group() as &[NameServerConfig]).into()
}
#[allow(unused)]
fn default_nameserver_group_ipv6_only() -> NameServerConfigGroup {
filter_ipv6(&default_nameserver_group() as &[NameServerConfig]).into()
}
/// Create a new resolver with the default configuration, which reads from the system DNS config
@@ -349,7 +469,12 @@ fn configure_and_build_resolver(
/// addresses to work with "happy eyeballs" algorithm.
fn new_resolver_system() -> Result<TokioResolver, ResolveError> {
let mut resolver_builder = TokioResolver::builder_tokio()?;
resolver_builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
let options = HickoryDnsResolver::default_options();
info!("building new fallback system resolver");
debug!("fallback system resolver with {options:?}");
resolver_builder = resolver_builder.with_options(options);
Ok(resolver_builder.build())
}
@@ -358,11 +483,54 @@ fn new_default_static_fallback() -> StaticResolver {
StaticResolver::new(constants::default_static_addrs())
}
/// Do a trial resolution using each nameserver individually to test which are working and which
/// fail to complete a lookup.
async fn trial_nameservers_inner(
name_servers: &[NameServerConfig],
) -> Vec<(NameServerConfig, Result<(), ResolveError>)> {
let mut trial_lookups = tokio::task::JoinSet::new();
for name_server in name_servers {
let ns = name_server.clone();
trial_lookups.spawn(async { (ns.clone(), trial_lookup(ns, "example.com").await) });
}
trial_lookups.join_all().await
}
/// Create an independent resolver that has only the provided nameserver and do one lookup for the
/// provided query target.
async fn trial_lookup(name_server: NameServerConfig, query: &str) -> Result<(), ResolveError> {
debug!("running ns trial {name_server:?} query={query}");
let resolver = configure_and_build_resolver(vec![name_server]);
match tokio::time::timeout(DEFAULT_OVERALL_LOOKUP_TIMEOUT, resolver.ipv4_lookup(query)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(ResolveError::Timeout),
}
}
#[cfg(test)]
mod test {
use super::*;
use itertools::Itertools;
use std::collections::HashMap;
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Instant,
};
/// IP addresses guaranteed to fail attempts to resolve
///
/// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
];
#[tokio::test]
async fn reqwest_with_custom_dns() {
@@ -421,99 +589,172 @@ mod test {
assert!(addrs.contains(&example_ip6));
Ok(())
}
}
#[cfg(test)]
mod failure_test {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
// Test the nameserver trial functionality with mostly nameservers guaranteed to be broken and
// one that should work.
#[tokio::test]
async fn trial_nameservers() {
let good_cf_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
/// IP addresses guaranteed to fail attempts to resolve
///
/// Addresses drawn from blocks set off by RFC5737 (ipv4) and RFC3849 (ipv6)
const GUARANTEED_BROKEN_IPS_1: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1111)),
IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0x1001)),
];
let mut ns_ips = GUARANTEED_BROKEN_IPS_1.to_vec();
ns_ips.push(good_cf_ip);
// Create a resolver that behaves the same as the custom configured router, except for the fact
// that it is guaranteed to fail.
fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new faulty resolver");
let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
GUARANTEED_BROKEN_IPS_1,
853,
"cloudflare-dns.com".to_string(),
true,
);
let broken_ns_https = NameServerConfigGroup::from_ips_https(
GUARANTEED_BROKEN_IPS_1,
&ns_ips,
443,
"cloudflare-dns.com".to_string(),
true,
);
broken_ns_group.merge(broken_ns_https);
configure_and_build_resolver(broken_ns_group)
}
#[tokio::test]
async fn dns_lookup_failures() -> Result<(), ResolveError> {
let time_start = std::time::Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
let inner = configure_and_build_resolver(broken_ns_https);
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
let domain = "ifconfig.me";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
let duration = time_start.elapsed();
assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
Ok(())
}
#[tokio::test]
async fn fallback_to_static() -> Result<(), ResolveError> {
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
dont_use_shared: true,
state: Arc::new(r),
use_shared: false,
state: Arc::new(OnceCell::with_value(inner)),
static_base: Some(Default::default()),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
// successful lookup using fallback to static resolver
let domain = "nymvpn.com";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
let name_servers = resolver.state.get().unwrap().config().name_servers();
for (ns, result) in trial_nameservers_inner(name_servers).await {
if ns.socket_addr.ip() == good_cf_ip {
assert!(result.is_ok())
} else {
assert!(result.is_err())
}
}
}
// unsuccessful lookup - primary times out, and not in
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
mod failure_test {
use super::*;
Ok(())
// Create a resolver that behaves the same as the custom configured router, except for the fact
// that it is guaranteed to fail.
fn build_broken_resolver() -> Result<TokioResolver, ResolveError> {
info!("building new faulty resolver");
let mut broken_ns_group = NameServerConfigGroup::from_ips_tls(
GUARANTEED_BROKEN_IPS_1,
853,
"cloudflare-dns.com".to_string(),
true,
);
let broken_ns_https = NameServerConfigGroup::from_ips_https(
GUARANTEED_BROKEN_IPS_1,
443,
"cloudflare-dns.com".to_string(),
true,
);
broken_ns_group.merge(broken_ns_https);
Ok(configure_and_build_resolver(broken_ns_group))
}
#[tokio::test]
async fn dns_lookup_failures() -> Result<(), ResolveError> {
let time_start = std::time::Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
use_shared: false,
state: Arc::new(r),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
let domain = "ifconfig.me";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
let duration = time_start.elapsed();
assert!(duration < resolver.overall_dns_timeout + Duration::from_secs(1));
Ok(())
}
#[tokio::test]
async fn fallback_to_static() -> Result<(), ResolveError> {
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver {
use_shared: false,
state: Arc::new(r),
static_base: Some(Default::default()),
overall_dns_timeout: Duration::from_secs(5),
..Default::default()
};
build_broken_resolver()?;
// successful lookup using fallback to static resolver
let domain = "nymvpn.com";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
// unsuccessful lookup - primary times out, and not in static table
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
Ok(())
}
#[test]
fn default_resolver_uses_ipv4_only_nameservers() {
let resolver = HickoryDnsResolver::thread_resolver();
resolver
.active_name_servers()
.iter()
.all(|cfg| cfg.socket_addr.is_ipv4());
SHARED_RESOLVER
.active_name_servers()
.iter()
.all(|cfg| cfg.socket_addr.is_ipv4());
}
#[tokio::test]
#[ignore]
// this test is dependent of external network setup -- i.e. blocking all traffic to the default
// resolvers. Otherwise the default resolvers will succeed without using the static fallback,
// making the test pointless
async fn dns_lookup_failure_on_shared() -> Result<(), ResolveError> {
let time_start = Instant::now();
let r = OnceCell::new();
r.set(build_broken_resolver().expect("failed to build resolver"))
.expect("broken resolver init error");
// create a new resolver that won't mess with the shared resolver used by other tests
let resolver = HickoryDnsResolver::default();
// successful lookup using fallback to static resolver
let domain = "rpc.nymtech.net";
let _ = resolver
.resolve_str(domain)
.await
.expect("failed to resolve address in static lookup");
println!(
"{}ms resolved {domain}",
(Instant::now() - time_start).as_millis()
);
// unsuccessful lookup - primary times out, and not in static table
let domain = "non-existent.nymtech.net";
let result = resolver.resolve_str(domain).await;
assert!(result.is_err());
// assert!(result.is_err_and(|e| matches!(e, ResolveError::Timeout)));
// assert!(result.is_err_and(|e| matches!(e, ResolveError::ResolveError(e) if e.is_nx_domain())));
Ok(())
}
}
}
+85
View File
@@ -121,4 +121,89 @@ mod tests {
// println!("{response:?}");
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn fallback_on_failure() {
let url1 = Url::new(
"https://fake-domain.nymtech.net",
Some(vec![
"https://fake-front-1.nymtech.net",
"https://fake-front-2.nymtech.net",
]),
)
.unwrap();
let url2 = Url::new(
"https://validator.global.ssl.fastly.net",
Some(vec!["https://yelp.global.ssl.fastly.net"]),
)
.unwrap(); // fastly
let client = ClientBuilder::new_with_urls(vec![url1, url2])
.expect("bad url")
.with_fronting(FrontPolicy::Always)
.build()
.expect("failed to build client");
// Check that the initial configuration has the broken domain and front.
assert_eq!(
client.current_url().as_str(),
"https://fake-domain.nymtech.net/",
);
assert_eq!(
client.current_url().front_str(),
Some("fake-front-1.nymtech.net"),
);
let result = client
.send_request::<_, (), &str, &str>(
reqwest::Method::GET,
&["api", "v1", "network", "details"],
NO_PARAMS,
None,
)
.await;
assert!(result.is_err());
// Check that the host configuration updated the front on error.
assert_eq!(
client.current_url().as_str(),
"https://fake-domain.nymtech.net/",
);
assert_eq!(
client.current_url().front_str(),
Some("fake-front-2.nymtech.net"),
);
let result = client
.send_request::<_, (), &str, &str>(
reqwest::Method::GET,
&["api", "v1", "network", "details"],
NO_PARAMS,
None,
)
.await;
assert!(result.is_err());
// Check that the host configuration updated the domain and front on error.
assert_eq!(
client.current_url().as_str(),
"https://validator.global.ssl.fastly.net/",
);
assert_eq!(
client.current_url().front_str(),
Some("yelp.global.ssl.fastly.net"),
);
let response = client
.send_request::<_, (), &str, &str>(
reqwest::Method::GET,
&["api", "v1", "network", "details"],
NO_PARAMS,
None,
)
.await
.expect("failed get request");
assert_eq!(response.status(), 200);
}
}
+89 -17
View File
@@ -175,7 +175,7 @@ mod user_agent;
pub use user_agent::UserAgent;
#[cfg(not(target_arch = "wasm32"))]
mod dns;
pub mod dns;
mod path;
#[cfg(not(target_arch = "wasm32"))]
@@ -395,6 +395,13 @@ pub enum HttpClientError {
#[error("failed to resolve request to {url} due to data inconsistency: {details}")]
InternalResponseInconsistency { url: ::url::Url, details: String },
#[cfg(not(target_arch = "wasm32"))]
#[error("encountered dns failure: {inner}")]
DnsLookupFailure {
#[from]
inner: ResolveError,
},
#[error("Failed to encode bincode: {0}")]
Bincode(#[from] bincode::Error),
@@ -421,6 +428,8 @@ impl HttpClientError {
HttpClientError::ReqwestClientError { source } => source.is_timeout(),
HttpClientError::RequestSendFailure { source, .. } => source.0.is_timeout(),
HttpClientError::ResponseReadFailure { source, .. } => source.0.is_timeout(),
#[cfg(not(target_arch = "wasm32"))]
HttpClientError::DnsLookupFailure { inner } => inner.is_timeout(),
#[cfg(target_arch = "wasm32")]
HttpClientError::RequestTimeout => true,
_ => false,
@@ -775,6 +784,7 @@ impl ClientBuilder {
base_urls: self.urls,
current_idx: Arc::new(AtomicUsize::new(0)),
reqwest_client,
using_secure_dns: self.use_secure_dns,
#[cfg(feature = "tunneling")]
front: self.front,
@@ -795,6 +805,7 @@ pub struct Client {
base_urls: Vec<Url>,
current_idx: Arc<AtomicUsize>,
reqwest_client: reqwest::Client,
using_secure_dns: bool,
#[cfg(feature = "tunneling")]
front: Option<fronted::Front>,
@@ -852,6 +863,7 @@ impl Client {
base_urls: vec![new_url],
current_idx: Arc::new(Default::default()),
reqwest_client: self.reqwest_client.clone(),
using_secure_dns: self.using_secure_dns,
#[cfg(feature = "tunneling")]
front: self.front.clone(),
@@ -883,8 +895,34 @@ impl Client {
self.retry_limit = limit;
}
fn matches_current_host(&self, url: &Url) -> bool {
if cfg!(feature = "tunneling") {
if let Some(ref front) = self.front
&& front.is_enabled()
{
url.host_str() == self.current_url().front_str()
} else {
url.host_str() == self.current_url().host_str()
}
} else {
url.host_str() == self.current_url().host_str()
}
}
/// If multiple base urls are available rotate to next (e.g. when the current one resulted in an error)
fn update_host(&self) {
///
/// Takes an optional URL argument. If this is none, the current host will be updated automatically.
/// If a url is provided first check that the CURRENT host matches the hostname in the URL before
/// triggering a rotation. This is meant to prevent parallel requests that fail from rotating the host
/// multiple times.
fn update_host(&self, maybe_url: Option<Url>) {
// If a causal url is provided and it doesn't match the hostname currently in use, skip update.
if let Some(err_url) = maybe_url
&& !self.matches_current_host(&err_url)
{
return;
}
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front
&& front.is_enabled()
@@ -1048,8 +1086,28 @@ impl ApiClientCore for Client {
.build()
.map_err(HttpClientError::reqwest_client_build_error)?;
self.apply_hosts_to_req(&mut req);
let url: Url = req.url().clone().into();
// try an explicit DNS resolution - if successful then it will be in cache when reqwest
// goes to execute the request. If failure then we get to handle the DNS lookup error.
#[cfg(not(target_arch = "wasm32"))]
let url = req.url().clone();
if self.using_secure_dns
&& let Some(hostname) = req.url().domain()
// Default here will use a shared resolver instance
&& let Err(err) = HickoryDnsResolver::default().resolve_str(hostname).await
{
// on failure update host, but don't trigger fronting enable.
self.update_host(Some(url.clone()));
if attempts < self.retry_limit {
attempts += 1;
warn!(
"Retrying request due to dns error on attempt ({attempts}/{}): {err}",
self.retry_limit
);
continue;
}
}
#[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError> = {
@@ -1067,25 +1125,39 @@ impl ApiClientCore for Client {
match response {
Ok(resp) => return Ok(resp),
Err(err) => {
// if we have multiple urls, update to the next
self.update_host();
// only if there was a network issue should we consider updating the host info
//
// note: for now this includes DNS resolution failure, I am not sure how I would go about
// segregating that based on the interface provided by request for errors.
#[cfg(target_arch = "wasm32")]
let is_network_err = err.is_timeout();
#[cfg(not(target_arch = "wasm32"))]
let is_network_err = err.is_timeout() || err.is_connect();
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
// If fronting is set to be enabled on error, enable domain fronting as we
// have encountered an error.
let was_enabled = front.is_enabled();
front.retry_enable();
if !was_enabled && front.is_enabled() {
tracing::info!(
"Domain fronting activated after connection failure: {err}",
);
if is_network_err {
// if we have multiple urls, update to the next
self.update_host(Some(url.clone()));
#[cfg(feature = "tunneling")]
if let Some(ref front) = self.front {
// If fronting is set to be enabled on error, enable domain fronting as we
// have encountered an error.
let was_enabled = front.is_enabled();
front.retry_enable();
if !was_enabled && front.is_enabled() {
tracing::info!(
"Domain fronting activated after connection failure: {err}",
);
}
}
}
if attempts < self.retry_limit {
warn!("Retrying request due to http error: {err}");
attempts += 1;
warn!(
"Retrying request due to http error on attempt ({attempts}/{}): {err}",
self.retry_limit
);
continue;
}
@@ -1094,7 +1166,7 @@ impl ApiClientCore for Client {
if #[cfg(target_arch = "wasm32")] {
return Err(err);
} else {
return Err(HttpClientError::request_send_error(url, err));
return Err(HttpClientError::request_send_error(url.into(), err));
}
}
}
+35 -11
View File
@@ -91,14 +91,15 @@ fn sanitizing_urls() {
#[tokio::test]
async fn api_client_retry() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new_with_urls(vec![
"http://broken.nym.test".parse()?, // This will fail
"https://httpbin.org/status/200".parse()?, // This will succeed
"http://broken.nym.test".parse()?, // This should fail because of DNS NXDomain (rotate)
"http://127.0.0.1:9".parse()?, // This will fail because of TCP refused (rotate)
"https://httpbin.org/status/200".parse()?, // This should succeed
])?
.with_retries(3)
.build()?;
let req = client.create_get_request(&[], NO_PARAMS).unwrap();
let resp = client.send(req).await?;
let _resp = client.send(req).await?;
// The main test is that we successfully retried and switched to the working URL
// We accept any response from the working endpoint since external services can be unreliable
@@ -107,7 +108,9 @@ async fn api_client_retry() -> Result<(), Box<dyn std::error::Error>> {
"https://httpbin.org/status/200"
);
println!("Response status: {}", resp.status());
// // This assert can be unreliable due to factors beyond our control and beyond the scope of
// // this test
// assert_eq!(_resp.status(), StatusCode::OK);
Ok(())
}
@@ -123,7 +126,7 @@ fn host_updating() {
assert_eq!(current_url.front_str(), None);
// update the url
client.update_host();
client.update_host(None);
// check that the url is still the same since there is one URL
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
@@ -138,13 +141,13 @@ fn host_updating() {
client.change_base_urls(new_urls);
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
client.update_host();
client.update_host(None);
// check that the url got updated now that there are multiple URLs
assert_eq!(client.current_url().as_str(), "http://nym-api2.test/");
assert_eq!(client.current_url().front_str(), None);
client.update_host();
client.update_host(None);
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
// =======================================
@@ -162,12 +165,33 @@ fn host_updating() {
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
client.update_host();
client.update_host(None);
// check that the url got updated now that there are multiple URLs
assert_eq!(client.current_url().as_str(), "http://nym-api2.test/");
}
#[test]
fn host_updating_url_conditioned() {
let url1 = Url::new("http://nym-api1.test", None).unwrap();
let url2 = Url::new("http://nym-api2.test", None).unwrap();
let urls = vec![url1.clone(), url2.clone()];
let client = ClientBuilder::new_with_urls(urls).unwrap().build().unwrap();
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
// Try to update with a URL that does NOT match current - should result in no change
client.update_host(Some(Url::parse("http://example.com").unwrap()));
// check that the url did NOT get updated
assert_eq!(client.current_url().as_str(), "http://nym-api1.test/");
assert_eq!(client.current_url().front_str(), None);
// Try to update with a URL that DOES match current - should result in no change
client.update_host(Some(url1));
assert_eq!(client.current_url().as_str(), "http://nym-api2.test/");
}
#[test]
#[cfg(feature = "tunneling")]
fn fronted_host_updating() {
@@ -184,7 +208,7 @@ fn fronted_host_updating() {
assert_eq!(current_url.front_str(), Some("cdn1.test"));
// update the url
client.update_host();
client.update_host(None);
// check that the url is still the same since there is one URL and one front
let current_url = client.current_url();
@@ -209,7 +233,7 @@ fn fronted_host_updating() {
assert_eq!(current_url.front_str(), Some("cdn1.test"));
// update the url - this should keep the same host but change the front
client.update_host();
client.update_host(None);
let current_url = client.current_url();
// check that the url is still the same since there is one URL
@@ -217,7 +241,7 @@ fn fronted_host_updating() {
assert_eq!(current_url.front_str(), Some("cdn2.test"));
// update the url - this should wrap around to the first front as the second url is not fronted
client.update_host();
client.update_host(None);
let current_url = client.current_url();
assert_eq!(current_url.as_str(), "http://nym-api.test/");
+21
View File
@@ -54,6 +54,11 @@ pub const NYM_APIS: &[ApiUrlConst] = &[
];
pub const NYM_VPN_API: &str = "https://nymvpn.com/api/";
pub const UPGRADE_MODE_ATTESTATION_URL: &str = "https://nym.com/upgrade-mode/attestation.json";
pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str =
"3bgffBYcfFkTTXc2npNNn9MkddFZ3H2LrPjXDmnJzrqd";
#[cfg(feature = "network")]
pub const NYM_VPN_APIS: &[ApiUrlConst] = &[
ApiUrlConst {
@@ -159,6 +164,14 @@ pub fn export_to_env() {
set_var_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS);
set_var_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL);
set_var_to_default(var_names::NYM_VPN_API, NYM_VPN_API);
set_var_to_default(
var_names::UPGRADE_MODE_ATTESTATION_URL,
UPGRADE_MODE_ATTESTATION_URL,
);
set_var_to_default(
var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY,
UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY,
);
}
#[cfg(all(feature = "env", feature = "network"))]
@@ -199,4 +212,12 @@ pub fn export_to_env_if_not_set() {
set_var_conditionally_to_default(var_names::NYM_API, NYM_API);
set_var_conditionally_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS);
set_var_conditionally_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL);
set_var_conditionally_to_default(
var_names::UPGRADE_MODE_ATTESTATION_URL,
UPGRADE_MODE_ATTESTATION_URL,
);
set_var_conditionally_to_default(
var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY,
UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY,
);
}
+2
View File
@@ -25,6 +25,8 @@ pub const NYXD_WEBSOCKET: &str = "NYXD_WS";
pub const EXIT_POLICY_URL: &str = "EXIT_POLICY";
pub const NYM_VPN_API: &str = "NYM_VPN_API";
pub const CLIENT_STATS_COLLECTION_PROVIDER: &str = "CLIENT_STATS_COLLECTION_PROVIDER";
pub const UPGRADE_MODE_ATTESTATION_URL: &str = "UPGRADE_MODE_ATTESTATION_URL";
pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = "UPGRADE_MODE_ATTESTER_ED25519_PUBKEY";
pub const DKG_TIME_CONFIGURATION: &str = "DKG_TIME_CONFIGURATION";
+6 -30
View File
@@ -28,6 +28,12 @@ pub struct EntryDetails {
pub clients_wss_port: Option<u16>,
}
impl std::fmt::Display for EntryDetails {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SupportedRoles {
pub mixnode: bool,
@@ -59,36 +65,6 @@ pub struct RoutingNode {
}
impl RoutingNode {
pub fn ws_entry_address_tls(&self) -> Option<String> {
let entry = self.entry.as_ref()?;
let hostname = entry.hostname.as_ref()?;
let wss_port = entry.clients_wss_port?;
Some(format!("wss://{hostname}:{wss_port}"))
}
pub fn ws_entry_address_no_tls(&self, prefer_ipv6: bool) -> Option<String> {
let entry = self.entry.as_ref()?;
if let Some(hostname) = entry.hostname.as_ref() {
return Some(format!("ws://{hostname}:{}", entry.clients_ws_port));
}
if prefer_ipv6 && let Some(ipv6) = entry.ip_addresses.iter().find(|ip| ip.is_ipv6()) {
return Some(format!("ws://{ipv6}:{}", entry.clients_ws_port));
}
let any_ip = entry.ip_addresses.first()?;
Some(format!("ws://{any_ip}:{}", entry.clients_ws_port))
}
pub fn ws_entry_address(&self, prefer_ipv6: bool) -> Option<String> {
if let Some(tls) = self.ws_entry_address_tls() {
return Some(tls);
}
self.ws_entry_address_no_tls(prefer_ipv6)
}
pub fn identity(&self) -> ed25519::PublicKey {
self.identity_key
}
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "nym-api"
license = "GPL-3.0"
version = "1.1.68"
version = "1.1.70"
authors.workspace = true
edition = "2021"
rust-version.workspace = true
@@ -453,7 +453,7 @@ impl PacketPreparer {
for gateway in &gateways_to_test_details {
let recipient = self.create_packet_sender(gateway);
let gateway_identity = gateway.identity_key;
let gateway_address = gateway.ws_entry_address(false);
let gateway_address = gateway.entry;
// the unwrap here is fine as:
// 1. the topology is definitely valid (otherwise we wouldn't be here)
@@ -1,6 +1,6 @@
[package]
name = "nym-credential-proxy"
version = "0.3.0-test"
version = "0.3.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -156,12 +156,8 @@ pub struct Cli {
#[derive(Args, Debug, Clone)]
pub struct UpgradeModeConfig {
/// URL for polling for upgrade mode changes.
#[clap(
long,
env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL",
default_value = "5m"
)]
pub(crate) attestation_check_url: Url,
#[clap(long, env = "NYM_CREDENTIAL_PROXY_ATTESTATION_CHECK_URL")]
pub(crate) attestation_check_url: Option<Url>,
/// Default polling interval of the upgrade mode endpoint.
#[clap(
@@ -8,6 +8,8 @@ use nym_bin_common::bin_info;
use nym_credential_proxy_lib::error::CredentialProxyError;
use nym_credential_proxy_lib::storage::CredentialProxyStorage;
use nym_credential_proxy_lib::ticketbook_manager::TicketbookManager;
use nym_network_defaults::var_names;
use nym_network_defaults::var_names::CONFIGURED;
use tracing::{error, info};
pub async fn wait_for_signal() {
@@ -55,6 +57,28 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> {
let webhook_cfg = cli.webhook;
let jwt_signing_keys = cli.jwt_signing_keys.signing_keys()?;
let upgrade_mode_attestation_check_url = match cli.upgrade_mode.attestation_check_url {
Some(url) => url,
None => {
// argument hasn't been provided and env is not configured
if std::env::var(CONFIGURED).is_err() {
return Err(CredentialProxyError::AttestationCheckUrlNotSet);
}
// argument hasn't been provided and the relevant env value hasn't been set
// (technically this shouldn't be possible)
let Ok(env_url) = std::env::var(var_names::UPGRADE_MODE_ATTESTATION_URL) else {
return Err(CredentialProxyError::AttestationCheckUrlNotSet);
};
match env_url.parse() {
Ok(url) => url,
Err(err) => {
return Err(CredentialProxyError::MalformedAttestationCheckUrl { source: err });
}
}
}
};
let ticketbook_manager = TicketbookManager::new(
build_sha_short(),
cli.quorum_check_interval,
@@ -70,7 +94,7 @@ pub(crate) async fn run_api(cli: Cli) -> Result<(), CredentialProxyError> {
cli.upgrade_mode.attestation_check_regular_polling_interval,
cli.upgrade_mode
.attestation_check_expedited_polling_interval,
cli.upgrade_mode.attestation_check_url,
upgrade_mode_attestation_check_url,
jwt_signing_keys,
cli.upgrade_mode.upgrade_mode_jwt_validity,
);
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-node"
version = "1.20.0"
version = "1.22.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
+68 -35
View File
@@ -3,7 +3,9 @@
use tokio_util::sync::CancellationToken;
use nym_authenticator_client::{AuthClientMixnetListener, AuthenticatorClient};
use nym_authenticator_client::{
AuthClientMixnetListener, AuthClientMixnetListenerHandle, AuthenticatorClient,
};
use nym_bandwidth_controller::BandwidthTicketProvider;
use nym_credentials_interface::TicketType;
use nym_ip_packet_client::IprClientConnect;
@@ -35,9 +37,22 @@ pub struct RegistrationClient {
event_rx: EventReceiver,
}
enum MixnetClientHandle {
Authenticator(AuthClientMixnetListenerHandle),
Sdk(Box<MixnetClient>),
}
impl MixnetClientHandle {
async fn stop(self) {
match self {
Self::Authenticator(handle) => handle.stop().await,
Self::Sdk(handle) => handle.disconnect().await,
}
}
}
// Bundle of an actual error and the underlying mixnet client so it can be shutdown correctly if needed
struct RegistrationError {
mixnet_client: Option<MixnetClient>,
mixnet_client_handle: MixnetClientHandle,
source: crate::RegistrationClientError,
}
@@ -49,7 +64,7 @@ impl RegistrationClient {
let Some(ipr_address) = self.config.exit.node.ipr_address else {
return Err(RegistrationError {
mixnet_client: Some(self.mixnet_client),
mixnet_client_handle: MixnetClientHandle::Sdk(Box::new(self.mixnet_client)),
source: RegistrationClientError::NoIpPacketRouterAddress {
node_id: self.config.exit.node.identity.to_base58_string(),
},
@@ -67,13 +82,17 @@ impl RegistrationClient {
Some(Ok(addr)) => addr,
Some(Err(e)) => {
return Err(RegistrationError {
mixnet_client: Some(ipr_client.into_mixnet_client()),
mixnet_client_handle: MixnetClientHandle::Sdk(Box::new(
ipr_client.into_mixnet_client(),
)),
source: RegistrationClientError::ConnectToIpPacketRouter(e),
});
}
None => {
return Err(RegistrationError {
mixnet_client: Some(ipr_client.into_mixnet_client()),
mixnet_client_handle: MixnetClientHandle::Sdk(Box::new(
ipr_client.into_mixnet_client(),
)),
source: RegistrationClientError::Cancelled,
});
}
@@ -97,7 +116,7 @@ impl RegistrationClient {
async fn register_wg(self) -> Result<RegistrationResult, RegistrationError> {
let Some(entry_auth_address) = self.config.entry.node.authenticator_address else {
return Err(RegistrationError {
mixnet_client: Some(self.mixnet_client),
mixnet_client_handle: MixnetClientHandle::Sdk(Box::new(self.mixnet_client)),
source: RegistrationClientError::AuthenticationNotPossible {
node_id: self.config.entry.node.identity.to_base58_string(),
},
@@ -106,7 +125,7 @@ impl RegistrationClient {
let Some(exit_auth_address) = self.config.exit.node.authenticator_address else {
return Err(RegistrationError {
mixnet_client: Some(self.mixnet_client),
mixnet_client_handle: MixnetClientHandle::Sdk(Box::new(self.mixnet_client)),
source: RegistrationClientError::AuthenticationNotPossible {
node_id: self.config.exit.node.identity.to_base58_string(),
},
@@ -150,35 +169,50 @@ impl RegistrationClient {
let exit_fut = exit_auth_client
.register_wireguard(&*self.bandwidth_controller, TicketType::V1WireguardExit);
let (entry, exit) = Box::pin(
let (entry, exit) = match Box::pin(
self.cancel_token
.run_until_cancelled(async { tokio::join!(entry_fut, exit_fut) }),
)
.await
.ok_or(RegistrationError {
mixnet_client: None,
source: RegistrationClientError::Cancelled,
})?;
{
Some((entry, exit)) => (entry, exit),
None => {
return Err(RegistrationError {
mixnet_client_handle: MixnetClientHandle::Authenticator(mixnet_listener),
source: RegistrationClientError::Cancelled,
});
}
};
let entry = entry.map_err(|source| RegistrationError {
mixnet_client: None,
source: RegistrationClientError::from_authenticator_error(
source,
self.config.entry.node.identity.to_base58_string(),
entry_auth_address,
true,
),
})?;
let entry = match entry {
Ok(entry) => entry,
Err(source) => {
return Err(RegistrationError {
mixnet_client_handle: MixnetClientHandle::Authenticator(mixnet_listener),
source: RegistrationClientError::from_authenticator_error(
source,
self.config.entry.node.identity.to_base58_string(),
entry_auth_address,
true,
),
});
}
};
let exit = exit.map_err(|source| RegistrationError {
mixnet_client: None,
source: RegistrationClientError::from_authenticator_error(
source,
self.config.exit.node.identity.to_base58_string(),
exit_auth_address,
false,
),
})?;
let exit = match exit {
Ok(exit) => exit,
Err(source) => {
return Err(RegistrationError {
mixnet_client_handle: MixnetClientHandle::Authenticator(mixnet_listener),
source: RegistrationClientError::from_authenticator_error(
source,
self.config.exit.node.identity.to_base58_string(),
exit_auth_address,
false,
),
});
}
};
Ok(RegistrationResult::Wireguard(Box::new(
WireguardRegistrationResult {
@@ -199,15 +233,14 @@ impl RegistrationClient {
self.register_mix_exit().await
};
// If we failed to register, and we were the owner of the mixnet client, shut it down
// If we failed to register, shut down the mixnet client and wait for it to exit
match registration_result {
Ok(result) => Ok(result),
Err(error) => {
debug!("Registration failed");
if let Some(mixnet_client) = error.mixnet_client {
debug!("Shutting down mixnet client");
mixnet_client.disconnect().await;
}
debug!("Shutting down mixnet client");
error.mixnet_client_handle.stop().await;
debug!("Mixnet client stopped");
Err(error.source)
}
}
+3 -2
View File
@@ -2864,9 +2864,9 @@ dependencies = [
[[package]]
name = "hickory-resolver"
version = "0.25.1"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a128410b38d6f931fcc6ca5c107a3b02cabd6c05967841269a4ad65d23c44331"
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
dependencies = [
"cfg-if",
"futures-util",
@@ -4219,6 +4219,7 @@ dependencies = [
"serde_plain",
"serde_yaml",
"thiserror 2.0.12",
"tokio",
"tracing",
"url",
"wasmtimer",
@@ -4,7 +4,7 @@
[package]
name = "nym-network-requester"
license = "GPL-3.0"
version = "1.1.66"
version = "1.1.68"
authors.workspace = true
edition.workspace = true
rust-version = "1.85"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-cli"
version = "1.1.65"
version = "1.1.67"
authors.workspace = true
edition = "2021"
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nymvisor"
version = "0.1.30"
version = "0.1.32"
authors.workspace = true
repository.workspace = true
homepage.workspace = true