Compare commits

..

13 Commits

Author SHA1 Message Date
Mark Sinclair 04ad1acd9f Merge pull request #6275 from nymtech/ip-fallback-cherrypick
cherrypick no_hostname only
2025-12-08 11:14:14 +00:00
Simon Wicky 5000e8ae39 cherrypick no_hostname only 2025-12-05 17:29:54 +01: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
31 changed files with 892 additions and 337 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
+8 -8
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",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "nym-credential-proxy"
version = "0.3.0-test"
version = "0.3.0"
dependencies = [
"anyhow",
"axum",
@@ -6363,7 +6363,7 @@ dependencies = [
[[package]]
name = "nym-network-requester"
version = "1.1.66"
version = "1.1.68"
dependencies = [
"addr",
"anyhow",
@@ -6413,7 +6413,7 @@ dependencies = [
[[package]]
name = "nym-node"
version = "1.20.0"
version = "1.22.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -6940,7 +6940,7 @@ dependencies = [
[[package]]
name = "nym-socks5-client"
version = "1.1.65"
version = "1.1.67"
dependencies = [
"bs58",
"clap",
@@ -7681,7 +7681,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"
@@ -87,6 +87,7 @@ where
user_chosen_gateway_id.map(|id| id.to_base58_string()),
Some(common_args.latency_based_selection),
common_args.force_tls_gateway,
false,
);
tracing::debug!("Gateway selection specification: {selection_spec:?}");
@@ -136,6 +136,7 @@ where
user_chosen_gateway_id.map(|id| id.to_base58_string()),
Some(common_args.latency_based_selection),
common_args.force_tls_gateway,
false,
);
tracing::debug!("Gateway selection specification: {selection_spec:?}");
+3
View File
@@ -43,6 +43,9 @@ pub enum ClientCoreError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("node doesn't advertise ip addresses : {0}")]
MissingIpAddress(String),
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::ResolveError),
+12 -5
View File
@@ -71,21 +71,28 @@ where
let mut rng = OsRng;
let selected_gateway = match selection_specification {
GatewaySelectionSpecification::UniformRemote { must_use_tls } => {
GatewaySelectionSpecification::UniformRemote {
must_use_tls,
no_hostname,
} => {
let gateway = uniformly_random_gateway(&mut rng, &available_gateways, must_use_tls)?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::RemoteByLatency { must_use_tls } => {
GatewaySelectionSpecification::RemoteByLatency {
must_use_tls,
no_hostname,
} => {
let gateway =
choose_gateway_by_latency(&mut rng, &available_gateways, must_use_tls).await?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::Specified {
must_use_tls,
no_hostname,
identity,
} => {
let gateway = get_specified_gateway(&identity, &available_gateways, must_use_tls)?;
SelectedGateway::from_topology_node(gateway, must_use_tls)?
SelectedGateway::from_topology_node(gateway, must_use_tls, no_hostname)?
}
GatewaySelectionSpecification::Custom {
gateway_identity,
+38 -14
View File
@@ -42,24 +42,32 @@ impl SelectedGateway {
pub fn from_topology_node(
node: RoutingNode,
must_use_tls: bool,
no_hostname: bool,
) -> Result<Self, ClientCoreError> {
// 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(),
})?
let (gateway_listener, _) = if must_use_tls {
// WSS main, no fallback
let primary =
node.ws_entry_address_tls()
.ok_or(ClientCoreError::UnsupportedWssProtocol {
gateway: node.identity_key.to_base58_string(),
})?;
(primary, None)
} else {
node.ws_entry_address(prefer_ipv6)
.ok_or(ClientCoreError::UnsupportedEntry {
let (maybe_primary, fallback) =
node.ws_entry_address_with_fallback(prefer_ipv6, no_hostname);
(
maybe_primary.ok_or(ClientCoreError::UnsupportedEntry {
id: node.node_id,
identity: node.identity_key.to_base58_string(),
})?
})?,
fallback,
)
};
let gateway_listener =
let gateway_listener_url =
Url::parse(&gateway_listener).map_err(|source| ClientCoreError::MalformedListener {
gateway_id: node.identity_key.to_base58_string(),
raw_listener: gateway_listener,
@@ -69,7 +77,7 @@ impl SelectedGateway {
Ok(SelectedGateway::Remote {
gateway_id: node.identity_key,
gateway_owner_address: None,
gateway_listener,
gateway_listener: gateway_listener_url,
})
}
@@ -150,15 +158,22 @@ impl InitialisationResult {
#[derive(Clone, Debug)]
pub enum GatewaySelectionSpecification {
/// Uniformly choose a random remote gateway.
UniformRemote { must_use_tls: bool },
UniformRemote {
must_use_tls: bool,
no_hostname: bool,
},
/// Should the new, remote, gateway be selected based on latency.
RemoteByLatency { must_use_tls: bool },
RemoteByLatency {
must_use_tls: bool,
no_hostname: bool,
},
/// Gateway with this specific identity should be chosen.
// JS: I don't really like the name of this enum variant but couldn't think of anything better at the time
Specified {
must_use_tls: bool,
no_hostname: bool,
identity: IdentityKey,
},
@@ -174,6 +189,7 @@ impl Default for GatewaySelectionSpecification {
fn default() -> Self {
GatewaySelectionSpecification::UniformRemote {
must_use_tls: false,
no_hostname: false,
}
}
}
@@ -183,16 +199,24 @@ impl GatewaySelectionSpecification {
gateway_identity: Option<String>,
latency_based_selection: Option<bool>,
must_use_tls: bool,
no_hostname: bool,
) -> Self {
if let Some(identity) = gateway_identity {
GatewaySelectionSpecification::Specified {
identity,
must_use_tls,
no_hostname,
}
} else if let Some(true) = latency_based_selection {
GatewaySelectionSpecification::RemoteByLatency { must_use_tls }
GatewaySelectionSpecification::RemoteByLatency {
must_use_tls,
no_hostname,
}
} else {
GatewaySelectionSpecification::UniformRemote { must_use_tls }
GatewaySelectionSpecification::UniformRemote {
must_use_tls,
no_hostname,
}
}
}
}
+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";
+39
View File
@@ -89,6 +89,45 @@ impl RoutingNode {
self.ws_entry_address_no_tls(prefer_ipv6)
}
pub fn ws_entry_address_with_fallback(
&self,
prefer_ipv6: bool,
no_hostname: bool,
) -> (Option<String>, Option<String>) {
let Some(entry) = &self.entry else {
return (None, None);
};
// Put hostname first if we want it
let maybe_hostname = if !no_hostname {
entry.hostname.clone()
} else {
None
};
// Put ipv6 first or keep them as is
let ips: Vec<&IpAddr> = if prefer_ipv6 {
entry
.ip_addresses
.iter()
.filter(|ip| ip.is_ipv6())
.chain(entry.ip_addresses.iter().filter(|ip| ip.is_ipv4()))
.collect()
} else {
entry.ip_addresses.iter().collect()
};
// chain everything and keep the top two as ws addresses
let ws_addresses: Vec<_> = maybe_hostname
.into_iter()
.chain(ips.into_iter().map(|ip| ip.to_string()))
.take(2)
.map(|host| format!("ws://{host}:{}", entry.clients_ws_port))
.collect();
(ws_addresses.first().cloned(), ws_addresses.get(1).cloned())
}
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
@@ -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
@@ -119,6 +119,7 @@ impl BuilderConfig {
.network_details(self.network_env)
.debug_config(debug_config)
.credentials_mode(true)
.no_hostname(true)
.with_remember_me(remember_me)
.custom_topology_provider(self.custom_topology_provider);
+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",
+17
View File
@@ -56,6 +56,7 @@ pub struct MixnetClientBuilder<S: MixnetClientStorage = Ephemeral> {
custom_shutdown: Option<ShutdownTracker>,
event_tx: Option<EventSender>,
force_tls: bool,
no_hostname: bool,
user_agent: Option<UserAgent>,
#[cfg(unix)]
connection_fd_callback: Option<Arc<dyn Fn(std::os::fd::RawFd) + Send + Sync>>,
@@ -101,6 +102,7 @@ impl MixnetClientBuilder<OnDiskPersistent> {
event_tx: None,
custom_gateway_transceiver: None,
force_tls: false,
no_hostname: false,
user_agent: None,
#[cfg(unix)]
connection_fd_callback: None,
@@ -134,6 +136,7 @@ where
custom_shutdown: None,
event_tx: None,
force_tls: false,
no_hostname: false,
user_agent: None,
#[cfg(unix)]
connection_fd_callback: None,
@@ -158,6 +161,7 @@ where
custom_shutdown: self.custom_shutdown,
event_tx: self.event_tx,
force_tls: self.force_tls,
no_hostname: self.no_hostname,
user_agent: self.user_agent,
#[cfg(unix)]
connection_fd_callback: self.connection_fd_callback,
@@ -229,6 +233,13 @@ where
self
}
/// Attempt to only choose a gateway with its IP address only, ignored if force_tls is set
#[must_use]
pub fn no_hostname(mut self, no_hostname: bool) -> Self {
self.no_hostname = no_hostname;
self
}
/// Enable paid coconut bandwidth credentials mode.
#[must_use]
pub fn enable_credentials_mode(mut self) -> Self {
@@ -341,6 +352,7 @@ where
client.custom_shutdown = self.custom_shutdown;
client.wait_for_gateway = self.wait_for_gateway;
client.force_tls = self.force_tls;
client.no_hostname = self.no_hostname;
client.user_agent = self.user_agent;
#[cfg(unix)]
if self.connection_fd_callback.is_some() {
@@ -393,6 +405,9 @@ where
/// Force the client to connect using wss protocol with the gateway.
force_tls: bool,
/// Force the client to pick gateway IP and not hostname, ignored if force_tls is set
no_hostname: bool,
/// Allows passing an externally controlled shutdown handle.
custom_shutdown: Option<ShutdownTracker>,
@@ -461,6 +476,7 @@ where
custom_gateway_transceiver: None,
wait_for_gateway: false,
force_tls: false,
no_hostname: false,
custom_shutdown: None,
event_tx,
user_agent: None,
@@ -580,6 +596,7 @@ where
self.config.user_chosen_gateway.clone(),
None,
self.force_tls,
self.no_hostname,
);
let available_gateways = self.available_gateways().await?;
@@ -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